Files
suixinkan_uikit/suixinkan/Features/TravelAlbum/ViewModels/WiredCameraTransferViewModel.swift

444 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// WiredCameraTransferViewModel.swift
// suixinkan
//
import Foundation
/// 线 ViewModel ImageCaptureCore
@MainActor
final class WiredCameraTransferViewModel {
let albumId: Int
let albumTitle: String
let headerPhone: String
let scenicSpotLabel: String?
private(set) var connectionState: ConnectionState = .idle {
didSet { notifyStateChanged() }
}
private(set) var photos: [TravelAlbumOTGPhotoItem] = [] {
didSet { notifyStateChanged() }
}
private(set) var selectedTab: TravelAlbumOTGTransferTab = .all {
didSet { notifyStateChanged() }
}
private(set) var selectedPhotoIds: Set<String> = [] {
didSet { notifyStateChanged() }
}
private(set) var selectUploadMode = false {
didSet { notifyStateChanged() }
}
private(set) var sonyMTPHint: String?
private(set) var isContentCatalogReady = false
let retouchOption = "不修图"
private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg
private(set) var transferModeOption = "边拍边传"
var onStateChange: (() -> Void)?
var onShowMessage: ((String) -> Void)?
var onLiveShotSaved: ((String) -> Void)?
private let connectionManager: any WiredCameraConnectionManaging
private let storage: TravelAlbumOTGPhotoStore
private let repository: TravelAlbumOTGPhotoRepository
private let uploader: any TravelAlbumOTGUploading
private let appStore: AppStore
private var currentDriver: CameraDriver?
private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:]
private var isUploading = false
@MainActor
init(
albumId: Int,
albumTitle: String,
headerPhone: String,
scenicSpotLabel: String? = nil,
connectionManager: (any WiredCameraConnectionManaging)? = nil,
storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(),
uploader: (any TravelAlbumOTGUploading)? = nil,
appStore: AppStore = .shared
) {
self.albumId = albumId
self.albumTitle = albumTitle.isEmpty ? "有线传输" : albumTitle
self.headerPhone = headerPhone
self.scenicSpotLabel = scenicSpotLabel
self.connectionManager = connectionManager ?? ConnectionManager.shared
self.storage = storage
self.repository = TravelAlbumOTGPhotoRepository(storage: storage)
self.uploader = uploader ?? TravelAlbumOTGUploader()
self.appStore = appStore
}
///
var cameraStatusText: String {
switch connectionState {
case .idle, .disconnected:
return "未连接 USB"
case .searching:
return "正在搜索"
case .connecting:
return "正在连接"
case .connected:
return "已连接"
case .failed:
return "连接失败"
}
}
///
var actionButtonText: String {
switch connectionState {
case .idle, .disconnected, .failed:
return "重新连接"
case .searching, .connecting:
return "等待连接"
case .connected:
return "刷新"
}
}
///
var deviceModelName: String {
switch connectionState {
case .connecting(let name), .connected(_, let name):
return name
default:
return "设备存储"
}
}
///
var availableStorageText: String {
"-- GB 可用"
}
/// Tab
var tabCounts: [Int] {
[
photos.count,
photos.filter { $0.status == .uploaded }.count,
photos.filter { $0.status == .failed }.count,
]
}
///
func filteredPhotos() -> [TravelAlbumOTGPhotoItem] {
switch selectedTab {
case .all:
return photos
case .uploaded:
return photos.filter { $0.status == .uploaded }
case .failed:
return photos.filter { $0.status == .failed }
}
}
///
func start() {
connectionManager.configureLiveTransfer(albumID: albumId)
connectionManager.delegate = self
syncFromConnectionManager()
loadPersistedPhotos()
connectionManager.start()
}
/// UI delegate
func stop() {
connectionManager.unbindDelegate()
connectionManager.suspendLiveTransfer()
}
///
func disconnect() {
connectionManager.disconnect()
}
///
func refreshCameraFiles() {
switch connectionState {
case .connected:
loadPersistedPhotos()
case .idle, .disconnected, .failed:
connectionManager.start()
default:
break
}
}
/// OTG
func reloadLocalPhotos() {
loadPersistedPhotos()
}
/// Tab
func selectTab(_ tab: TravelAlbumOTGTransferTab) {
selectedTab = tab
selectedPhotoIds = selectedPhotoIds.intersection(Set(filteredPhotos().map(\.id)))
}
///
func selectTransferMode(_ option: String) {
guard option == "边拍边传" || option == "拍后传输" else { return }
transferModeOption = option
}
///
func selectPhotoFormat(_ option: TravelAlbumOTGPhotoFormatOption) {
guard photoFormatOption != option else { return }
photoFormatOption = option
applyMergedPhotos()
}
///
func onBatchUploadButtonClick() {
if !selectUploadMode {
selectUploadMode = true
selectedPhotoIds = []
return
}
if selectedPhotoIds.isEmpty {
selectUploadMode = false
return
}
uploadSelectedTransferPhotos()
}
///
func onSpecifyUploadButtonClick() {
let ids = Set(photos.filter(\.isNotUploaded).map(\.id))
startUploadByIds(ids, emptyMessage: "暂无未上传的照片")
}
/// ViewModel
func makeCameraImportViewModel() -> TravelAlbumCameraImportViewModel? {
guard isContentCatalogReady else {
showMessage("相机照片目录尚未就绪,请稍候再试")
return nil
}
guard let currentDriver else {
showMessage("请先连接相机")
return nil
}
if case .connected = connectionState {
return TravelAlbumCameraImportViewModel(
albumId: albumId,
albumTitle: albumTitle,
driver: currentDriver,
storage: storage,
appStore: appStore
)
}
showMessage("请先连接相机")
return nil
}
/// PTP ImageCaptureCore
func pauseLiveTransferForHistoricalImport() {
connectionManager.suspendLiveTransfer()
}
///
func toggleTransferPhotoSelection(photoId: String) {
guard selectUploadMode,
photos.first(where: { $0.id == photoId })?.canSelectForUpload == true else {
return
}
if selectedPhotoIds.contains(photoId) {
selectedPhotoIds.remove(photoId)
} else {
selectedPhotoIds.insert(photoId)
}
}
///
func uploadSelectedTransferPhotos() {
let ids = selectedPhotoIds
guard !ids.isEmpty else {
showMessage("请先选择照片")
return
}
selectUploadMode = false
selectedPhotoIds = []
startUploadByIds(ids, emptyMessage: "所选照片不可上传")
}
///
func retryPhoto(photoId: String) {
startUploadByIds([photoId], emptyMessage: "本地文件不存在,无法重传")
}
///
func deletePhoto(photoId: String) {
storage.remove(albumId: albumId, photoId: photoId)
persistedRecordsById.removeValue(forKey: photoId)
selectedPhotoIds.remove(photoId)
applyMergedPhotos()
showMessage("已删除")
}
/// OTG
func clearLocalAlbumCache() {
storage.clearAlbum(albumId: albumId)
persistedRecordsById = [:]
photos = []
}
private func syncFromConnectionManager() {
currentDriver = connectionManager.currentDriver
isContentCatalogReady = connectionManager.isContentCatalogReady
connectionState = connectionManager.state
}
private func loadPersistedPhotos() {
let records = storage.load(albumId: albumId)
persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) })
applyMergedPhotos()
}
private func applyMergedPhotos() {
let persistedItems = persistedRecordsById.values
.map { $0.toPhotoItem(storage: storage, albumId: albumId) }
photos = persistedItems.sorted { $0.capturedAt > $1.capturedAt }
}
private func startUploadByIds(_ ids: Set<String>, emptyMessage: String) {
guard !isUploading else {
showMessage("正在上传,请稍候")
return
}
let targets = resolveUploadTargets(ids)
guard !targets.isEmpty else {
showMessage(emptyMessage)
return
}
isUploading = true
showMessage("开始上传 \(targets.count) 张照片")
Task {
for id in targets {
await uploadPhoto(id: id)
}
isUploading = false
notifyStateChanged()
}
}
private func resolveUploadTargets(_ ids: Set<String>) -> [String] {
ids.filter { persistedRecordsById[$0] != nil }
}
private func uploadPhoto(id: String) async {
do {
let record = try await localRecordForUpload(id: id)
updateRecord(id: id, status: .uploading, progress: max(record.progress, 1), error: nil)
let material = try await uploader.upload(
record: record,
scenicId: appStore.currentScenicId
) { [weak self] progress in
self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil)
}
updateRecord(id: id, status: .uploaded, progress: 100, error: nil, remoteUrl: material.fileUrl)
} catch {
updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription)
showMessage(error.localizedDescription)
}
}
private func localRecordForUpload(id: String) async throws -> TravelAlbumOTGPhotoRecord {
if let record = persistedRecordsById[id],
!record.localPath.isEmpty,
storage.fileExists(relativePath: record.localPath, albumId: albumId) {
return storage.recordWithResolvedFilePaths(record, albumId: albumId)
}
throw TravelAlbumOTGUploadError.localFileMissing
}
private func updateRecord(
id: String,
status: TravelAlbumOTGUploadStatus,
progress: Int,
error: String?,
remoteUrl: String? = nil
) {
var record = persistedRecordsById[id]
if record == nil, let item = photos.first(where: { $0.id == id }) {
record = TravelAlbumOTGPhotoRecord(
id: item.id,
sourceId: item.sourceId,
fileName: item.fileName,
localPath: item.localPath,
thumbnailPath: item.thumbnailURL?.isFileURL == true
? storage.relativePath(for: item.thumbnailURL ?? URL(fileURLWithPath: ""), albumId: albumId)
: "",
capturedAt: item.capturedAt,
fileSizeBytes: item.fileSizeBytes,
status: status,
progress: progress,
errorMessage: error,
albumId: albumId,
userId: appStore.userId,
remoteUrl: remoteUrl ?? item.remoteUrl
)
}
guard var record else { return }
record.status = status
record.progress = min(max(progress, 0), 100)
record.errorMessage = error
if let remoteUrl {
record.remoteUrl = remoteUrl
}
record.updatedAt = Int64(Date().timeIntervalSince1970 * 1000)
persistedRecordsById[id] = record
storage.upsert(record, albumId: albumId)
applyMergedPhotos()
}
private func notifyStateChanged() {
if Thread.isMainThread {
onStateChange?()
} else {
DispatchQueue.main.async { [weak self] in self?.onStateChange?() }
}
}
private func showMessage(_ message: String) {
if Thread.isMainThread {
onShowMessage?(message)
} else {
DispatchQueue.main.async { [weak self] in self?.onShowMessage?(message) }
}
}
}
extension WiredCameraTransferViewModel: ConnectionManagerDelegate {
func connectionManager(_ manager: ConnectionManager, didUpdate state: ConnectionState) {
connectionState = state
}
func connectionManager(_ manager: ConnectionManager, didCreate driver: any CameraDriver) {
currentDriver = driver
}
func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: any CameraDriver) {
currentDriver = driver
isContentCatalogReady = true
notifyStateChanged()
}
func connectionManager(_ manager: ConnectionManager, didFail error: any Error) {
showMessage(error.localizedDescription)
}
func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) {
showMessage("长时间未找到相机,请尝试重新插拔连接线")
}
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot filename: String, albumID: Int) {
guard albumID == albumId else { return }
loadPersistedPhotos()
onLiveShotSaved?(filename)
}
}