Files
Kisani/Features/Browse/GalleryView.swift

430 lines
15 KiB
Swift
Raw Normal View History

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
@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
private let columns = [
GridItem(.flexible(), spacing: 2),
GridItem(.flexible(), spacing: 2),
GridItem(.flexible(), spacing: 2)
]
var body: some View {
ZStack {
AppTheme.background.ignoresSafeArea()
switch source {
case .nas: nasContent
case .local: localContent
}
}
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) { sourceChip }
}
.task {
await loadNAS()
loadLocal()
}
.refreshable {
await loadNAS()
loadLocal()
}
}
// MARK: Source chip / dropdown
private var sourceChip: some View {
Menu {
Button {
withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { source = .nas }
} 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))
.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
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) })
}
}
}
// MARK: Shared grid
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())
.foregroundStyle(AppTheme.inkTertiary)
}
Spacer()
if nasLoading {
ProgressView().scaleEffect(0.75).tint(AppTheme.inkTertiary)
}
}
.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
}
}
// 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
}
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
}
}
}
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())
.foregroundStyle(AppTheme.inkTertiary)
.multilineTextAlignment(.center)
.padding(.horizontal, 40)
}
}
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))
}
.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
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)
}
}
.aspectRatio(1, contentMode: .fit)
.clipped()
.task { await load() }
}
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
}
}
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() }
}
}
loading = false
}
}
}