feat: redesign Gallery as photo timeline with sync state, deduplication, and detail view
- GalleryItem: unified model with phone/nas/synced source, sync state enum - GalleryViewModel: off-actor PHFetchResult + manifest merge, Phone/NAS/All tab filtering, date section grouping, Combine-driven reactivity - GalleryView: sticky date section headers, 3-col grid, shimmer placeholders, sync dots (green/orange/blue), video duration badge, search bar, context menu (Share, Save to Photos, Delete from Device) - GalleryDetailView: full-screen swipeable photo viewer (TabView page mode), pinch-to-zoom, double-tap zoom toggle, bottom metadata sheet with file info and actions - BackupStatusService: expose lastManifest for gallery deduplication without extra NAS connection - ThumbnailCache: add decodeOffThread static helper for off-actor JPEG decode - BackupManifest: Sendable conformance for safe Task.detached capture - ThumbnailCache: synced items load from PHImageManager (instant) instead of NAS Co-Authored-By: Kutesir <kutesir@provoc.ug> Co-Authored-By: Sentry <sentry@provoc.ug>
This commit is contained in:
@@ -2,31 +2,13 @@ import SwiftUI
|
||||
import Photos
|
||||
import UIKit
|
||||
|
||||
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"]
|
||||
|
||||
enum GallerySource { case nas, local }
|
||||
|
||||
struct GalleryView: View {
|
||||
@EnvironmentObject var store: ConnectionStore
|
||||
@EnvironmentObject var lanMonitor: LANMonitor
|
||||
@EnvironmentObject var statusService: BackupStatusService
|
||||
@StateObject private var viewModel = GalleryViewModel()
|
||||
|
||||
@State private var source: GallerySource = .nas
|
||||
|
||||
// NAS state
|
||||
@State private var nasItems: [NASItem] = []
|
||||
@State private var nasLoading = false
|
||||
@State private var nasError: String? = nil
|
||||
|
||||
// Local state
|
||||
@State private var localAssets: [PHAsset] = []
|
||||
@State private var syncedNames: Set<String> = []
|
||||
@State private var localAuthDenied = false
|
||||
|
||||
@State private var appeared = false
|
||||
|
||||
private var connection: NASConnection? { store.savedConnection }
|
||||
private let deviceName = UIDevice.current.name
|
||||
@State private var selectedItem: GalleryItem?
|
||||
@State private var searchVisible = false
|
||||
|
||||
private let columns = [
|
||||
GridItem(.flexible(), spacing: 2),
|
||||
@@ -35,395 +17,388 @@ struct GalleryView: View {
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
ZStack(alignment: .top) {
|
||||
AppTheme.background.ignoresSafeArea()
|
||||
|
||||
switch source {
|
||||
case .nas: nasContent
|
||||
case .local: localContent
|
||||
VStack(spacing: 0) {
|
||||
tabBar
|
||||
if searchVisible { searchBar }
|
||||
mainContent
|
||||
}
|
||||
}
|
||||
.navigationTitle("")
|
||||
.navigationTitle("Gallery")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) { sourceChip }
|
||||
.toolbar { trailingItems }
|
||||
.fullScreenCover(item: $selectedItem) { item in
|
||||
GalleryDetailView(
|
||||
item: item,
|
||||
allItems: viewModel.allItems,
|
||||
connection: store.savedConnection
|
||||
)
|
||||
}
|
||||
.task {
|
||||
await loadNAS()
|
||||
loadLocal()
|
||||
await viewModel.load(connection: store.savedConnection, statusService: statusService)
|
||||
}
|
||||
.refreshable {
|
||||
await loadNAS()
|
||||
loadLocal()
|
||||
await viewModel.load(connection: store.savedConnection, statusService: statusService)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Source chip / dropdown
|
||||
// MARK: — Toolbar
|
||||
|
||||
private var sourceChip: some View {
|
||||
Menu {
|
||||
private var trailingItems: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { source = .nas }
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
|
||||
searchVisible.toggle()
|
||||
if !searchVisible { viewModel.searchText = "" }
|
||||
}
|
||||
} label: {
|
||||
Label(connection?.host ?? "NAS", systemImage: "externaldrive.fill")
|
||||
}
|
||||
Button {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { source = .local }
|
||||
} label: {
|
||||
Label(deviceName, systemImage: "iphone")
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 5) {
|
||||
Circle()
|
||||
.fill(dotColor)
|
||||
.frame(width: 6, height: 6)
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.8), value: dotColor == AppTheme.positive)
|
||||
Text(source == .nas ? (connection?.host ?? "NAS") : deviceName)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
Image(systemName: searchVisible ? "xmark.circle.fill" : "magnifyingglass")
|
||||
.font(.system(size: 16, weight: .medium))
|
||||
.foregroundStyle(AppTheme.inkSecondary)
|
||||
.lineLimit(1)
|
||||
Image(systemName: "chevron.down")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 5)
|
||||
.background(AppTheme.surfaceSunken)
|
||||
.clipShape(Capsule(style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
private var dotColor: Color {
|
||||
switch source {
|
||||
case .nas:
|
||||
switch lanMonitor.nasReachable {
|
||||
case true: return AppTheme.positive
|
||||
case false: return AppTheme.destructive
|
||||
case nil: return AppTheme.inkQuaternary
|
||||
}
|
||||
case .local:
|
||||
return AppTheme.interactive
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — NAS content
|
||||
|
||||
private var nasContent: some View {
|
||||
Group {
|
||||
if connection == nil {
|
||||
placeholder(icon: "externaldrive.badge.xmark", text: "No NAS connected")
|
||||
} else if nasLoading && nasItems.isEmpty {
|
||||
loadingView("Loading gallery…")
|
||||
} else if let err = nasError {
|
||||
errorView(err) { Task { await loadNAS() } }
|
||||
} else if nasItems.isEmpty {
|
||||
placeholder(icon: "photo.on.rectangle", text: "No photos backed up yet")
|
||||
} else {
|
||||
gallery(items: nasItems.map { .nas($0) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Local content
|
||||
// MARK: — Tab bar
|
||||
|
||||
private var localContent: some View {
|
||||
Group {
|
||||
if localAuthDenied {
|
||||
placeholder(icon: "lock.slash", text: "Photo library access denied")
|
||||
} else if localAssets.isEmpty {
|
||||
loadingView("Loading photos…")
|
||||
} else {
|
||||
gallery(items: localAssets.map { .local($0) })
|
||||
private var tabBar: some View {
|
||||
Picker("", selection: $viewModel.tab) {
|
||||
ForEach(GalleryViewModel.Tab.allCases, id: \.self) { tab in
|
||||
Text(tab.rawValue).tag(tab)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// MARK: — Shared grid
|
||||
// MARK: — Search
|
||||
|
||||
private func gallery(items: [GalleryItem]) -> some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||
Section {
|
||||
LazyVGrid(columns: columns, spacing: 2) {
|
||||
ForEach(Array(items.enumerated()), id: \.offset) { idx, item in
|
||||
GalleryCell(
|
||||
item: item,
|
||||
connection: connection,
|
||||
isSynced: syncStatus(for: item)
|
||||
)
|
||||
.opacity(appeared ? 1 : 0)
|
||||
.animation(
|
||||
.spring(response: 0.35, dampingFraction: 0.82)
|
||||
.delay(Double(min(idx, 18)) * 0.02),
|
||||
value: appeared
|
||||
)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
gridHeader(count: items.count)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear { appeared = true }
|
||||
}
|
||||
|
||||
private func gridHeader(count: Int) -> some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("\(count) files")
|
||||
.font(.system(size: 14, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Group {
|
||||
switch source {
|
||||
case .nas:
|
||||
if let conn = connection {
|
||||
Text((conn.remotePath as NSString).lastPathComponent)
|
||||
}
|
||||
case .local:
|
||||
Text(deviceName)
|
||||
}
|
||||
}
|
||||
.font(AppTheme.micro())
|
||||
private var searchBar: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
TextField("Search filenames", text: $viewModel.searchText)
|
||||
.font(AppTheme.body(14))
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
if !viewModel.searchText.isEmpty {
|
||||
Button { viewModel.searchText = "" } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 13))
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 7)
|
||||
.background(AppTheme.surfaceSunken)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.bottom, 6)
|
||||
.transition(.move(edge: .top).combined(with: .opacity))
|
||||
}
|
||||
|
||||
// MARK: — Content states
|
||||
|
||||
@ViewBuilder
|
||||
private var mainContent: some View {
|
||||
if viewModel.isLoading && viewModel.sections.isEmpty {
|
||||
Spacer()
|
||||
VStack(spacing: 12) {
|
||||
ProgressView().tint(AppTheme.inkTertiary)
|
||||
Text("Loading gallery…")
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
Spacer()
|
||||
if nasLoading {
|
||||
ProgressView().scaleEffect(0.75).tint(AppTheme.inkTertiary)
|
||||
} else if viewModel.sections.isEmpty {
|
||||
Spacer()
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: emptyIcon)
|
||||
.font(.system(size: 38, weight: .ultraLight))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
Text(emptyMessage)
|
||||
.font(AppTheme.caption())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.vertical, 10)
|
||||
.background(AppTheme.background)
|
||||
}
|
||||
|
||||
// MARK: — Sync status
|
||||
|
||||
private func syncStatus(for item: GalleryItem) -> SyncStatus {
|
||||
guard source == .local else { return .none }
|
||||
switch item {
|
||||
case .local(let asset):
|
||||
let name = PHAssetResource.assetResources(for: asset)
|
||||
.first?.originalFilename.lowercased() ?? ""
|
||||
return syncedNames.contains(name) ? .synced : .pending
|
||||
case .nas:
|
||||
return .none
|
||||
Spacer()
|
||||
} else {
|
||||
photoGrid
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Data
|
||||
|
||||
private func loadNAS() async {
|
||||
guard let conn = connection else { return }
|
||||
nasLoading = true
|
||||
nasError = nil
|
||||
do {
|
||||
let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
|
||||
try await s.connect(to: conn.host, port: conn.port,
|
||||
username: conn.username, password: conn.password)
|
||||
nasItems = try await s.listDirectory(at: conn.remotePath)
|
||||
.filter { !$0.isDirectory }
|
||||
.sorted { ($0.modifiedDate ?? .distantPast) > ($1.modifiedDate ?? .distantPast) }
|
||||
s.disconnect()
|
||||
syncedNames = Set(nasItems.map { $0.name.lowercased() })
|
||||
} catch let e as BackupError {
|
||||
nasError = e.errorDescription
|
||||
} catch {
|
||||
nasError = error.localizedDescription
|
||||
private var emptyIcon: String {
|
||||
switch viewModel.tab {
|
||||
case .all, .phone: "photo.on.rectangle.angled"
|
||||
case .nas: "externaldrive.badge.questionmark"
|
||||
}
|
||||
nasLoading = false
|
||||
}
|
||||
|
||||
private func loadLocal() {
|
||||
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
|
||||
guard status == .authorized || status == .limited else {
|
||||
if status == .denied || status == .restricted { localAuthDenied = true }
|
||||
PHPhotoLibrary.requestAuthorization(for: .readWrite) { granted in
|
||||
Task { @MainActor in
|
||||
if granted == .authorized || granted == .limited {
|
||||
loadLocalAssets()
|
||||
} else {
|
||||
localAuthDenied = true
|
||||
private var emptyMessage: String {
|
||||
let q = viewModel.searchText.trimmingCharacters(in: .whitespaces)
|
||||
if !q.isEmpty { return "No results for \"\(q)\"" }
|
||||
switch viewModel.tab {
|
||||
case .all: return "No photos yet"
|
||||
case .phone: return "No photos on this device"
|
||||
case .nas: return "No backed-up files found"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Grid
|
||||
|
||||
private var photoGrid: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) {
|
||||
ForEach(viewModel.sections) { section in
|
||||
Section {
|
||||
LazyVGrid(columns: columns, spacing: 2) {
|
||||
ForEach(section.items) { item in
|
||||
GalleryCell(item: item, connection: store.savedConnection)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture { selectedItem = item }
|
||||
.contextMenu { itemContextMenu(item) }
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
sectionHeader(for: section)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
loadLocalAssets()
|
||||
}
|
||||
|
||||
private func loadLocalAssets() {
|
||||
let opts = PHFetchOptions()
|
||||
opts.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
|
||||
let result = PHAsset.fetchAssets(with: opts)
|
||||
var assets: [PHAsset] = []
|
||||
result.enumerateObjects { asset, _, _ in assets.append(asset) }
|
||||
localAssets = assets
|
||||
}
|
||||
|
||||
// MARK: — Utility views
|
||||
|
||||
private func loadingView(_ text: String) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
ProgressView().tint(AppTheme.inkTertiary)
|
||||
Text(text).font(AppTheme.caption()).foregroundStyle(AppTheme.inkTertiary)
|
||||
}
|
||||
}
|
||||
|
||||
private func placeholder(icon: String, text: String) -> some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 32, weight: .ultraLight))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
Text(text)
|
||||
.font(AppTheme.caption())
|
||||
private func sectionHeader(for section: GallerySection) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Text(section.title)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
Spacer()
|
||||
Text("\(section.items.count)")
|
||||
.font(AppTheme.micro())
|
||||
.foregroundStyle(AppTheme.inkTertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
.padding(.horizontal, AppTheme.hPad)
|
||||
.padding(.vertical, 7)
|
||||
.background(.ultraThinMaterial)
|
||||
}
|
||||
|
||||
private func errorView(_ message: String, retry: @escaping () -> Void) -> some View {
|
||||
VStack(spacing: 16) {
|
||||
placeholder(icon: "wifi.exclamationmark", text: message)
|
||||
Button(action: retry) {
|
||||
Text("Retry")
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(AppTheme.ink)
|
||||
.padding(.horizontal, 16).padding(.vertical, 8)
|
||||
.background(AppTheme.surfaceSunken)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
|
||||
// MARK: — Context menu
|
||||
|
||||
@ViewBuilder
|
||||
private func itemContextMenu(_ item: GalleryItem) -> some View {
|
||||
Button {
|
||||
selectedItem = item
|
||||
} label: {
|
||||
Label("View", systemImage: "eye")
|
||||
}
|
||||
|
||||
if item.source.localIdentifier != nil {
|
||||
Button { sharePhoneAsset(item) } label: {
|
||||
Label("Share", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
|
||||
if case .nas = item.source {
|
||||
Button {
|
||||
Task { await viewModel.storeLocally(item: item, connection: store.savedConnection) }
|
||||
} label: {
|
||||
Label("Save to Photos", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
if item.source.localIdentifier != nil {
|
||||
Button(role: .destructive) { deleteFromDevice(item) } label: {
|
||||
Label("Delete from Device", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sharePhoneAsset(_ item: GalleryItem) {
|
||||
guard let id = item.source.localIdentifier else { return }
|
||||
let result = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil)
|
||||
guard let asset = result.firstObject else { return }
|
||||
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: nil) { data, _, _, _ in
|
||||
guard let data, let img = UIImage(data: data) else { return }
|
||||
let vc = UIActivityViewController(activityItems: [img], applicationActivities: nil)
|
||||
guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
|
||||
let root = scene.windows.first?.rootViewController else { return }
|
||||
DispatchQueue.main.async { root.present(vc, animated: true) }
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteFromDevice(_ item: GalleryItem) {
|
||||
guard let id = item.source.localIdentifier else { return }
|
||||
let result = PHAsset.fetchAssets(withLocalIdentifiers: [id], options: nil)
|
||||
guard result.count > 0 else { return }
|
||||
PHPhotoLibrary.shared().performChanges {
|
||||
PHAssetChangeRequest.deleteAssets(result)
|
||||
} completionHandler: { success, _ in
|
||||
if success {
|
||||
Task { @MainActor in
|
||||
await viewModel.load(connection: store.savedConnection, statusService: statusService)
|
||||
}
|
||||
}
|
||||
.buttonStyle(ScaleButtonStyle())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Gallery item model
|
||||
|
||||
enum GalleryItem {
|
||||
case nas(NASItem)
|
||||
case local(PHAsset)
|
||||
}
|
||||
|
||||
enum SyncStatus { case none, synced, pending }
|
||||
|
||||
// MARK: — Cell
|
||||
|
||||
struct GalleryCell: View {
|
||||
let item: GalleryItem
|
||||
let connection: NASConnection?
|
||||
let isSynced: SyncStatus
|
||||
|
||||
@State private var image: UIImage? = nil
|
||||
@State private var loading = false
|
||||
@State private var image: UIImage?
|
||||
@State private var taskID = UUID()
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Group {
|
||||
if let img = image {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.transition(.opacity.animation(.easeIn(duration: 0.18)))
|
||||
} else {
|
||||
Rectangle()
|
||||
.fill(AppTheme.surfaceSunken)
|
||||
.overlay {
|
||||
if loading {
|
||||
ProgressView().scaleEffect(0.55).tint(AppTheme.inkQuaternary)
|
||||
} else {
|
||||
Image(systemName: cellIcon)
|
||||
.font(.system(size: 18, weight: .ultraLight))
|
||||
.foregroundStyle(AppTheme.inkQuaternary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Video badge
|
||||
if isVideo && image != nil {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 9, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(3)
|
||||
.background(.black.opacity(0.45))
|
||||
.clipShape(Circle())
|
||||
.padding(5)
|
||||
}
|
||||
|
||||
// Sync status badge (local mode only)
|
||||
if isSynced != .none {
|
||||
Circle()
|
||||
.fill(isSynced == .synced ? AppTheme.positive : Color(red: 1.0, green: 0.58, blue: 0.0))
|
||||
.frame(width: 9, height: 9)
|
||||
.overlay(Circle().stroke(Color.white, lineWidth: 1.5))
|
||||
.padding(5)
|
||||
.animation(.spring(response: 0.25, dampingFraction: 0.8), value: isSynced == .synced)
|
||||
ZStack {
|
||||
cellBackground
|
||||
if item.mediaType == .video, image != nil {
|
||||
videoOverlay.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading)
|
||||
}
|
||||
syncDot.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
|
||||
}
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.clipped()
|
||||
.task { await load() }
|
||||
.id(taskID)
|
||||
.task(id: taskID) { await loadThumbnail() }
|
||||
}
|
||||
|
||||
private var isVideo: Bool {
|
||||
switch item {
|
||||
case .nas(let n): return videoExts.contains((n.name as NSString).pathExtension.lowercased())
|
||||
case .local(let a): return a.mediaType == .video
|
||||
@ViewBuilder
|
||||
private var cellBackground: some View {
|
||||
if let img = image {
|
||||
Image(uiImage: img)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.transition(.opacity.animation(.easeIn(duration: 0.18)))
|
||||
} else {
|
||||
Rectangle()
|
||||
.fill(AppTheme.surfaceSunken)
|
||||
.shimmer()
|
||||
}
|
||||
}
|
||||
|
||||
private var cellIcon: String {
|
||||
switch item {
|
||||
case .nas(let n):
|
||||
let ext = (n.name as NSString).pathExtension.lowercased()
|
||||
return videoExts.contains(ext) ? "play.rectangle" : "photo"
|
||||
case .local(let a):
|
||||
return a.mediaType == .video ? "play.rectangle" : "photo"
|
||||
}
|
||||
}
|
||||
|
||||
private func load() async {
|
||||
guard image == nil, !loading else { return }
|
||||
|
||||
switch item {
|
||||
case .nas(let nasItem):
|
||||
guard let conn = connection else { return }
|
||||
let ext = (nasItem.name as NSString).pathExtension.lowercased()
|
||||
guard imageExts.contains(ext) || videoExts.contains(ext) else { return }
|
||||
if let cached = ThumbnailCache.shared.get(for: nasItem.path) { image = cached; return }
|
||||
loading = true
|
||||
do {
|
||||
let s: any NASTransferProtocol = conn.nasProtocol == .smb ? SMBService() : SFTPService()
|
||||
try await s.connect(to: conn.host, port: conn.port,
|
||||
username: conn.username, password: conn.password)
|
||||
let data = try await s.downloadData(at: nasItem.path)
|
||||
s.disconnect()
|
||||
if let img = UIImage(data: data)?.thumbnailScaled(to: 300) {
|
||||
ThumbnailCache.shared.set(img, for: nasItem.path)
|
||||
image = img
|
||||
}
|
||||
} catch {}
|
||||
loading = false
|
||||
|
||||
case .local(let asset):
|
||||
loading = true
|
||||
let opts = PHImageRequestOptions()
|
||||
opts.deliveryMode = .opportunistic
|
||||
opts.isNetworkAccessAllowed = true
|
||||
opts.isSynchronous = false
|
||||
let size = CGSize(width: 300, height: 300)
|
||||
await withCheckedContinuation { cont in
|
||||
PHImageManager.default().requestImage(
|
||||
for: asset, targetSize: size,
|
||||
contentMode: .aspectFill, options: opts
|
||||
) { img, info in
|
||||
if let img { image = img }
|
||||
let degraded = info?[PHImageResultIsDegradedKey] as? Bool ?? false
|
||||
if !degraded { cont.resume() }
|
||||
}
|
||||
private var videoOverlay: some View {
|
||||
HStack(spacing: 3) {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
if let dur = item.duration {
|
||||
Text(formatDuration(dur))
|
||||
.font(.system(size: 9, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 3)
|
||||
.background(.black.opacity(0.55))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
|
||||
.padding(5)
|
||||
}
|
||||
|
||||
private var syncDot: some View {
|
||||
let color: Color = switch item.syncState {
|
||||
case .synced: AppTheme.positive
|
||||
case .phoneOnly: Color(red: 1.0, green: 0.58, blue: 0.0)
|
||||
case .nasOnly: AppTheme.interactive
|
||||
}
|
||||
return Circle()
|
||||
.fill(color)
|
||||
.frame(width: 8, height: 8)
|
||||
.overlay(Circle().stroke(Color.white, lineWidth: 1.5))
|
||||
.padding(5)
|
||||
}
|
||||
|
||||
private func formatDuration(_ s: TimeInterval) -> String {
|
||||
let t = Int(s)
|
||||
return t >= 3600
|
||||
? String(format: "%d:%02d:%02d", t / 3600, (t % 3600) / 60, t % 60)
|
||||
: String(format: "%d:%02d", t / 60, t % 60)
|
||||
}
|
||||
|
||||
// MARK: — Thumbnail loading
|
||||
|
||||
private func loadThumbnail() async {
|
||||
guard image == nil else { return }
|
||||
switch item.source {
|
||||
case .phone(let id), .synced(let id, _):
|
||||
image = await loadPhoneImage(localID: id)
|
||||
case .nas(let path):
|
||||
image = await loadNASImage(path: path)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadPhoneImage(localID: String) async -> UIImage? {
|
||||
let result = PHAsset.fetchAssets(withLocalIdentifiers: [localID], options: nil)
|
||||
guard let asset = result.firstObject else { return nil }
|
||||
|
||||
let opts = PHImageRequestOptions()
|
||||
opts.deliveryMode = .fastFormat
|
||||
opts.isNetworkAccessAllowed = false
|
||||
opts.isSynchronous = false
|
||||
|
||||
return await withCheckedContinuation { cont in
|
||||
PHImageManager.default().requestImage(
|
||||
for: asset,
|
||||
targetSize: CGSize(width: 300, height: 300),
|
||||
contentMode: .aspectFill,
|
||||
options: opts
|
||||
) { img, _ in cont.resume(returning: img) }
|
||||
}
|
||||
}
|
||||
|
||||
private func loadNASImage(path: String) async -> UIImage? {
|
||||
if let cached = ThumbnailCache.shared.get(for: path) { return cached }
|
||||
guard let conn = connection else { return nil }
|
||||
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()
|
||||
if let img = await ThumbnailCache.decodeOffThread(data: data, size: 300) {
|
||||
ThumbnailCache.shared.set(img, for: path)
|
||||
return img
|
||||
}
|
||||
} catch {}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: — Shimmer
|
||||
|
||||
private struct ShimmerModifier: ViewModifier {
|
||||
@State private var phase: CGFloat = -1
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content.overlay(
|
||||
GeometryReader { geo in
|
||||
LinearGradient(
|
||||
colors: [.clear, Color.white.opacity(0.25), .clear],
|
||||
startPoint: .leading,
|
||||
endPoint: .trailing
|
||||
)
|
||||
.frame(width: geo.size.width * 2)
|
||||
.offset(x: phase * geo.size.width * 2)
|
||||
.animation(
|
||||
.linear(duration: 1.4).repeatForever(autoreverses: false),
|
||||
value: phase
|
||||
)
|
||||
}
|
||||
.clipped()
|
||||
)
|
||||
.onAppear { phase = 1 }
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func shimmer() -> some View { modifier(ShimmerModifier()) }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user