// // WiredCameraTransferViewModel.swift // suixinkan // import Darwin import Foundation /// 手机相册导入到 OTG 本地缓存的图片数据。 struct TravelAlbumPhoneAlbumImportItem: Sendable, Equatable { let data: Data let fileName: String let createdAt: Date /// 创建手机相册导入图片。 init(data: Data, fileName: String, createdAt: Date = Date()) { self.data = data self.fileName = fileName self.createdAt = createdAt } } /// OTG 自动修图预览的数据校验错误,避免进入缺少素材或结果的空白预览页。 enum TravelAlbumOTGPreviewError: LocalizedError, Equatable { case materialUnavailable case resultNotReady /// 预览入口可直接展示给用户的错误说明。 var errorDescription: String? { switch self { case .materialUnavailable: return "素材信息暂不可用,请返回相册刷新后重试" case .resultNotReady: return "修图结果暂未同步,请稍后重试" } } } /// 有线相机传输页 ViewModel,编排 ImageCaptureCore 连接、照片导入、本地缓存与上传登记。 @MainActor final class WiredCameraTransferViewModel { private static let transferModeDefaultsKey = "travelAlbum.otg.transferMode" 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 { normalizeSelectedTimeSlot() notifyStateChanged() } } private(set) var selectedTab: TravelAlbumOTGTransferTab = .all { didSet { normalizeSelectedTimeSlot() notifyStateChanged() } } private(set) var selectedPhotoIds: Set = [] { didSet { notifyStateChanged() } } private(set) var selectUploadMode = false { didSet { notifyStateChanged() } } private(set) var sidebarExpanded = true { didSet { notifyStateChanged() } } private(set) var selectedTimeSlotId: String? { didSet { guard !suppressSelectedTimeSlotNotification else { return } notifyStateChanged() } } private(set) var sonyMTPHint: String? private(set) var isContentCatalogReady = false private(set) var autoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration { didSet { notifyStateChanged() } } private(set) var isUpdatingAutoRetouchConfiguration = false { didSet { notifyStateChanged() } } private(set) var photoFormatOption: TravelAlbumOTGPhotoFormatOption = .jpg private(set) var transferMode: TravelAlbumOTGTransferMode = .liveUpload { didSet { notifyStateChanged() } } 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 api: any TravelAlbumServing private let appStore: AppStore private let userDefaults: UserDefaults private var currentDriver: CameraDriver? private var persistedRecordsById: [String: TravelAlbumOTGPhotoRecord] = [:] private var isUploading = false private var queuedAutoUploadPhotoIds: Set = [] private var suppressSelectedTimeSlotNotification = false private var serverStatusSyncTask: Task? private var configurationSyncTask: Task? private var autoRetouchPollingTask: Task? @MainActor init( albumId: Int, albumTitle: String, headerPhone: String, scenicSpotLabel: String? = nil, initialTransferMode: TravelAlbumOTGTransferMode? = nil, initialAutoRetouchConfiguration: TravelAlbumAutoRetouchConfiguration = .disabled, connectionManager: (any WiredCameraConnectionManaging)? = nil, storage: TravelAlbumOTGPhotoStore = TravelAlbumOTGPhotoStore(), uploader: (any TravelAlbumOTGUploading)? = nil, api: (any TravelAlbumServing)? = nil, appStore: AppStore = .shared, userDefaults: UserDefaults = .standard ) { 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.api = api ?? NetworkServices.shared.travelAlbumAPI self.appStore = appStore self.userDefaults = userDefaults self.autoRetouchConfiguration = initialAutoRetouchConfiguration self.transferMode = initialTransferMode ?? Self.persistedTransferMode(in: userDefaults) if let initialTransferMode { userDefaults.set(initialTransferMode.rawValue, forKey: Self.transferModeDefaultsKey) } } /// 相机状态文案。 var cameraStatusText: String { switch connectionState { case .idle, .disconnected: return "未连接 USB" case .searching: return "正在搜索" case .connecting: return "正在连接" case .connected(_, let name): return "\(name)已连接" case .failed: return "连接失败" } } /// 操作按钮文案。 var actionButtonText: String { switch connectionState { case .idle, .disconnected, .failed: return "重新连接" case .searching, .connecting: return "等待连接" case .connected: return "刷新" } } /// 当前手机型号名称。 var deviceModelName: String { Self.currentPhoneModelName() } /// 当前手机可用存储空间。 var availableStorageText: String { Self.availableStorageText() } /// 是否已连接相机。 var isCameraConnected: Bool { if case .connected = connectionState { return true } return false } /// 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 } } } /// 过滤后照片的半小时时间分段。 var photoSections: [TravelAlbumOTGPhotoSection] { filteredPhotos().buildPhotoSections() } /// 过滤后照片的左侧时间导航分组。 var sidebarGroups: [TravelAlbumOTGDateGroup] { filteredPhotos().buildSidebarGroups() } /// 当前可见列表中可被选择上传的照片 ID。 var selectableVisiblePhotoIds: Set { Set(filteredPhotos().filter(\.canSelectForUpload).map(\.id)) } /// 当前可见列表中是否已全选可上传照片。 var allVisibleSelectablePhotosSelected: Bool { let ids = selectableVisiblePhotoIds return !ids.isEmpty && ids.isSubset(of: selectedPhotoIds) } /// 当前可见列表中已选择的可上传照片数量。 var selectedVisibleUploadCount: Int { selectedPhotoIds.intersection(selectableVisiblePhotoIds).count } /// 传输模式展示文案。 var transferModeOption: String { transferMode.title } /// 自动修图设置 Chip 的展示文案。 var retouchOption: String { autoRetouchConfiguration.displayTitle } /// 自动修图设置页复用当前注入的相册服务。 var autoRetouchAPI: any TravelAlbumServing { api } /// 获取已完成自动修图照片的最新原图与精修图,仅供只读预览,不改变上传或修图状态。 func loadAutoRetouchPreviewProject(photoId: String) async throws -> TravelAlbumPreviewProject { try Task.checkCancellation() guard let record = persistedRecordsById[photoId], record.autoRetouchState == .completed, record.serverMaterialId > 0 else { throw TravelAlbumOTGPreviewError.materialUnavailable } let material = try await api.materialInfo( userEquityTravelId: albumId, materialId: record.serverMaterialId ) try Task.checkCancellation() let project = TravelAlbumPreviewProject(material: material) guard material.id == record.serverMaterialId, let original = project.asset(for: .original), !original.displayURL.isEmpty else { throw TravelAlbumOTGPreviewError.materialUnavailable } guard let retouched = project.asset(for: .retouched), !retouched.displayURL.isEmpty else { throw TravelAlbumOTGPreviewError.resultNotReady } return TravelAlbumPreviewProject( originalMaterialId: project.originalMaterialId, aiRetouchBatchId: project.aiRetouchBatchId, assets: [original, retouched] ) } /// 指定上传弹窗选项。 var specifyUploadOptions: [TravelAlbumOTGSpecifyUploadOption] { TravelAlbumOTGSpecifyUploadOption.allCases } /// 是否可打开指定上传选项。 var canOpenSpecifyUploadChooser: Bool { !selectUploadMode } /// 绑定连接管理器并启动发现。 func start() { connectionManager.configureLiveTransfer(albumID: albumId) connectionManager.delegate = self syncFromConnectionManager() loadPersistedPhotos() syncServerUploadStatuses() syncAutoRetouchConfiguration() resumePendingAutoRetouches() updateAutoRetouchPolling() connectionManager.start() } /// 页面消失时解绑 UI delegate,并暂停边拍边传轮询,保留底层设备缓存。 func stop() { serverStatusSyncTask?.cancel() serverStatusSyncTask = nil configurationSyncTask?.cancel() configurationSyncTask = nil autoRetouchPollingTask?.cancel() autoRetouchPollingTask = nil connectionManager.unbindDelegate() connectionManager.suspendLiveTransfer() } /// App 进入后台时停止配置请求和任务轮询,保留待恢复状态。 func applicationDidEnterBackground() { configurationSyncTask?.cancel() configurationSyncTask = nil autoRetouchPollingTask?.cancel() autoRetouchPollingTask = nil } /// App 回到前台时同步跨设备配置并恢复自动修图任务。 func applicationDidBecomeActive() { syncAutoRetouchConfiguration() resumePendingAutoRetouches() updateAutoRetouchPolling() } /// 主动断开相机连接。 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 toggleSidebar() { sidebarExpanded.toggle() } /// 选择左侧时间槽,并联动主列表滚动。 func selectTimeSlot(id: String) { guard photoSections.contains(where: { $0.slotId == id }) else { return } selectedTimeSlotId = id } /// 同步主列表当前可见时间槽,只更新左侧时间轴选中态,不触发整页重新绑定。 func syncVisibleTimeSlot(id: String) { guard selectedTimeSlotId != id, photoSections.contains(where: { $0.slotId == id }) else { return } suppressSelectedTimeSlotNotification = true selectedTimeSlotId = id suppressSelectedTimeSlotNotification = false } /// 切换传输模式。 func selectTransferMode(_ option: String) { guard let mode = TravelAlbumOTGTransferMode.option(title: option), mode != transferMode else { return } transferMode = mode userDefaults.set(mode.rawValue, forKey: Self.transferModeDefaultsKey) } /// 保存相册级自动修图配置;服务端成功后才更新页面状态。 func updateAutoRetouchConfiguration(_ configuration: TravelAlbumAutoRetouchConfiguration) async { guard !isUpdatingAutoRetouchConfiguration else { return } guard configuration.isValid else { showMessage("请选择有效的 AI 修图模板") return } isUpdatingAutoRetouchConfiguration = true defer { isUpdatingAutoRetouchConfiguration = false } do { autoRetouchConfiguration = try await api.updateAutoRetouchConfiguration( TravelAlbumAutoRetouchConfigurationRequest( userEquityTravelId: albumId, enabled: configuration.enabled, refinedTemplateId: configuration.refinedTemplateId ) ) showMessage(autoRetouchConfiguration.enabled ? "已开启 AI 自动修图" : "已关闭 AI 自动修图") } catch { showMessage(error.localizedDescription.isEmpty ? "自动修图设置保存失败" : error.localizedDescription) } } /// 重试单张已上传照片的自动修图,不重复上传原图。 func retryAutoRetouch(photoId: String) { guard var record = persistedRecordsById[photoId], record.status == .uploaded, record.serverMaterialId > 0, record.autoRetouchState == .failed, record.autoRetouchTemplateId != nil else { showMessage("当前照片无法重新修图") return } if record.autoRetouchBatchId > 0 { record.autoRetouchAttempt += 1 record.autoRetouchBatchId = 0 record.autoRetouchClientRequestId = makeAutoRetouchClientRequestId(record: record) } record.autoRetouchState = .pendingSubmission record.autoRetouchErrorMessage = nil persist(record) Task { await submitAutoRetouch(photoId: photoId) } } /// 切换上传格式。 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 onSpecifyUploadOptionSelected(_ option: TravelAlbumOTGSpecifyUploadOption) { guard canOpenSpecifyUploadChooser else { return } switch option { case .allPending: uploadAllNotUploadedPhotos() case .todayCaptured: uploadAllTodayCapturedPhotos() } } /// 创建相机历史照片选择导入页 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() } /// 处理本次新传入 App 的照片,按当前传输模式决定是否自动上传。 func handleNewlyImportedPhotoIds(_ photoIds: [String]) { let ids = Set(photoIds) loadPersistedPhotos() guard !ids.isEmpty, transferMode.shouldAutoUploadNewImports else { return } startUploadByIds( ids, emptyMessage: "暂无可自动上传的照片", queueIfBusy: true ) } /// 将手机相册选择的图片导入当前 OTG 相册本地缓存。 func importPhoneAlbumImages(_ items: [TravelAlbumPhoneAlbumImportItem]) -> (importedCount: Int, failedCount: Int, importedPhotoIds: [String]) { guard !items.isEmpty else { return (0, 0, []) } var importedPhotoIds: [String] = [] var failedCount = 0 for item in items { do { let fileURL = try storage.writeImage(item.data, filename: item.fileName, albumId: albumId) let record = TravelAlbumOTGPhotoRecord( id: "phone_album_\(UUID().uuidString)", fileName: fileURL.lastPathComponent, localPath: storage.relativePath(for: fileURL, albumId: albumId), capturedAt: TravelAlbumOTGPhotoStore.transferTimeText(item.createdAt), fileSizeBytes: Int64(item.data.count), status: .pending, albumId: albumId, userId: appStore.session.userId ) storage.upsert(record, albumId: albumId) importedPhotoIds.append(record.id) } catch { failedCount += 1 OTGLog.error(.connection, "phone album import failed: \(item.fileName) \(error.localizedDescription)") } } handleNewlyImportedPhotoIds(importedPhotoIds) return (importedPhotoIds.count, failedCount, importedPhotoIds) } /// 选择或取消选择照片。 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 toggleVisibleSelectablePhotos() { guard selectUploadMode else { return } let ids = selectableVisiblePhotoIds guard !ids.isEmpty else { return } if ids.isSubset(of: selectedPhotoIds) { selectedPhotoIds.subtract(ids) } else { selectedPhotoIds.formUnion(ids) } } /// 上传选中照片。 func uploadSelectedTransferPhotos() { let ids = selectedPhotoIds guard !ids.isEmpty else { showMessage("请先选择照片") return } selectUploadMode = false selectedPhotoIds = [] startUploadByIds(ids, emptyMessage: "所选照片不可上传") } private func uploadAllNotUploadedPhotos() { let ids = Set(photos.filter(\.isNotUploaded).map(\.id)) startUploadByIds(ids, emptyMessage: "暂无未上传的照片") } private func uploadAllTodayCapturedPhotos() { let ids = Set(photos.filter { photo in guard photo.isNotUploaded, let capturedDate = photo.capturedDate() else { return false } return Calendar.current.isDateInToday(capturedDate) }.map(\.id)) 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 syncServerUploadStatuses() { serverStatusSyncTask?.cancel() guard albumId > 0 else { return } let baseline = persistedRecordsById serverStatusSyncTask = Task { [weak self] in guard let self else { return } do { let response = try await api.materialClientPhotoIds(userEquityTravelId: albumId) try Task.checkCancellation() let activeIds = Set(persistedRecordsById.values.compactMap { record in record.status == .transferring || record.status == .uploading ? record.id : nil }) let reconciled = TravelAlbumOTGServerStatusPolicy.reconcile( records: Array(persistedRecordsById.values), baselineRecordsById: baseline, activePhotoIds: activeIds, serverClientPhotoIds: response.clientPhotoIds, now: Int64(Date().timeIntervalSince1970 * 1000) ) let reconciledById = Dictionary(uniqueKeysWithValues: reconciled.map { ($0.id, $0) }) guard reconciledById != persistedRecordsById else { return } persistedRecordsById = reconciledById storage.save(reconciled, albumId: albumId) applyMergedPhotos() selectedPhotoIds = selectedPhotoIds.intersection(selectableVisiblePhotoIds) } catch is CancellationError { return } catch { OTGLog.error(.connection, "sync server upload statuses failed: \(error.localizedDescription)") } } } private func syncAutoRetouchConfiguration() { configurationSyncTask?.cancel() configurationSyncTask = Task { [weak self] in await self?.refreshAutoRetouchConfiguration() } } private func refreshAutoRetouchConfiguration() async { do { let album = try await api.info(id: albumId) try Task.checkCancellation() autoRetouchConfiguration = album.autoRetouchConfiguration } catch is CancellationError { return } catch { OTGLog.error(.connection, "sync auto retouch configuration failed: \(error.localizedDescription)") } } private func resumePendingAutoRetouches() { let photoIds = persistedRecordsById.values.compactMap { record in record.status == .uploaded && record.autoRetouchState == .pendingSubmission ? record.id : nil } guard !photoIds.isEmpty else { return } Task { [weak self] in guard let self else { return } for photoId in photoIds { await submitAutoRetouch(photoId: photoId) } } } private func submitAutoRetouch(photoId: String) async { guard var record = persistedRecordsById[photoId], record.status == .uploaded, record.serverMaterialId > 0, let templateId = record.autoRetouchTemplateId, record.autoRetouchState == .pendingSubmission || record.autoRetouchState == .failed else { return } if record.autoRetouchClientRequestId.isEmpty { record.autoRetouchClientRequestId = makeAutoRetouchClientRequestId(record: record) } record.autoRetouchState = .submitting record.autoRetouchErrorMessage = nil persist(record) do { let submission = try await api.submitAIRetouch( TravelAlbumAIRetouchRequest( userEquityTravelId: albumId, materialIds: [record.serverMaterialId], refinedTemplateId: templateId, atmosphereTemplateId: nil, coverTemplateId: nil, clientRequestId: record.autoRetouchClientRequestId ) ) guard var latest = persistedRecordsById[photoId] else { return } latest.autoRetouchBatchId = submission.aiRetouchBatchId latest.autoRetouchState = autoRetouchState(for: submission.status) latest.autoRetouchErrorMessage = latest.autoRetouchState == .failed ? submission.status.title : nil persist(latest) updateAutoRetouchPolling() } catch is CancellationError { guard var latest = persistedRecordsById[photoId], latest.autoRetouchState == .submitting else { return } latest.autoRetouchState = .pendingSubmission persist(latest) } catch { guard var latest = persistedRecordsById[photoId] else { return } if isAmbiguousAutoRetouchSubmissionFailure(error) { latest.autoRetouchState = .pendingSubmission latest.autoRetouchErrorMessage = "网络中断,恢复后将自动继续修图" persist(latest) showMessage(latest.autoRetouchErrorMessage ?? "网络恢复后将自动继续修图") return } latest.autoRetouchState = .failed latest.autoRetouchErrorMessage = error.localizedDescription.isEmpty ? "AI 修图任务提交失败" : error.localizedDescription persist(latest) showMessage(latest.autoRetouchErrorMessage ?? "AI 修图任务提交失败") } } private func updateAutoRetouchPolling() { guard autoRetouchPollingTask == nil, persistedRecordsById.values.contains(where: { $0.autoRetouchState == .processing && $0.autoRetouchBatchId > 0 }) else { return } autoRetouchPollingTask = Task { [weak self] in guard let self else { return } while !Task.isCancelled { await refreshAutoRetouchJobs() guard persistedRecordsById.values.contains(where: { $0.autoRetouchState == .processing && $0.autoRetouchBatchId > 0 }) else { break } try? await Task.sleep(for: .seconds(8)) } autoRetouchPollingTask = nil } } private func refreshAutoRetouchJobs() async { let batchIds = Set(persistedRecordsById.values.compactMap { record in record.autoRetouchState == .processing && record.autoRetouchBatchId > 0 ? record.autoRetouchBatchId : nil }) for batchId in batchIds { do { let detail = try await api.aiRetouchJobInfo(batchId: batchId) try Task.checkCancellation() let state = autoRetouchState(for: detail.status) guard state != .processing else { continue } let matchingRecords = persistedRecordsById.values.filter { $0.autoRetouchBatchId == batchId } for var record in matchingRecords { record.autoRetouchState = state record.autoRetouchErrorMessage = state == .failed ? detail.status.title : nil persist(record) } } catch is CancellationError { return } catch { OTGLog.error(.connection, "poll auto retouch job failed: \(batchId) \(error.localizedDescription)") } } } private func autoRetouchState(for status: TravelAlbumAIJobStatus) -> TravelAlbumAutoRetouchState { switch status { case .queued, .processing, .unknown: return .processing case .succeeded, .partiallySucceeded: return .completed case .failed, .canceled: return .failed } } private func makeAutoRetouchClientRequestId(record: TravelAlbumOTGPhotoRecord) -> String { let templateId = record.autoRetouchTemplateId ?? 0 return "auto-\(albumId)-\(record.serverMaterialId)-tpl\(templateId)-a\(record.autoRetouchAttempt)" } private func isAmbiguousAutoRetouchSubmissionFailure(_ error: Error) -> Bool { guard let apiError = error as? APIError else { return false } switch apiError { case .networkFailed, .invalidResponse, .emptyData, .decodeFailed: return true case .httpStatus(let status, _): return status == 408 || status >= 500 case .invalidURL, .serverCode: return false } } private func applyMergedPhotos() { let persistedItems = persistedRecordsById.values .map { $0.toPhotoItem(storage: storage, albumId: albumId) } photos = persistedItems.sorted { $0.capturedAt > $1.capturedAt } } private func normalizeSelectedTimeSlot() { let sections = filteredPhotos().buildPhotoSections() guard let firstSlotId = sections.first?.slotId else { selectedTimeSlotId = nil return } if selectedTimeSlotId == nil || !sections.contains(where: { $0.slotId == selectedTimeSlotId }) { selectedTimeSlotId = firstSlotId } } private func startUploadByIds( _ ids: Set, emptyMessage: String, queueIfBusy: Bool = false ) { guard !isUploading else { if queueIfBusy { queuedAutoUploadPhotoIds.formUnion(ids) } else { showMessage("正在上传,请稍候") } return } let targets = resolveUploadTargets(ids) guard !targets.isEmpty else { showMessage(emptyMessage) return } isUploading = true Task { for id in targets { await uploadPhoto(id: id) } isUploading = false notifyStateChanged() uploadQueuedAutoImportsIfNeeded() } } private func uploadQueuedAutoImportsIfNeeded() { let ids = queuedAutoUploadPhotoIds queuedAutoUploadPhotoIds = [] guard !ids.isEmpty else { return } startUploadByIds( ids, emptyMessage: "暂无可自动上传的照片", queueIfBusy: true ) } private func resolveUploadTargets(_ ids: Set) -> [String] { ids.filter { id in guard let record = persistedRecordsById[id] else { return false } return record.status == .pending || record.status == .failed } } private func uploadPhoto(id: String) async { let retouchSnapshot = autoRetouchConfiguration 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.session.currentScenicId ) { [weak self] progress in self?.updateRecord(id: id, status: .uploading, progress: progress, error: nil) } completeOriginalUpload(id: id, material: material, retouchSnapshot: retouchSnapshot) if retouchSnapshot.enabled { await submitAutoRetouch(photoId: id) } } catch { updateRecord(id: id, status: .failed, progress: 0, error: error.localizedDescription) showMessage(error.localizedDescription) } } private func completeOriginalUpload( id: String, material: TravelAlbumMaterial, retouchSnapshot: TravelAlbumAutoRetouchConfiguration ) { guard var record = persistedRecordsById[id] else { updateRecord(id: id, status: .uploaded, progress: 100, error: nil, remoteUrl: material.fileUrl) return } record.status = .uploaded record.progress = 100 record.errorMessage = nil record.remoteUrl = material.fileUrl record.serverMaterialId = material.id record.autoRetouchTemplateId = retouchSnapshot.enabled ? retouchSnapshot.refinedTemplateId : nil record.autoRetouchBatchId = 0 record.autoRetouchAttempt = 0 record.autoRetouchErrorMessage = nil if retouchSnapshot.enabled, retouchSnapshot.refinedTemplateId != nil { record.autoRetouchState = .pendingSubmission record.autoRetouchClientRequestId = makeAutoRetouchClientRequestId(record: record) } else { record.autoRetouchState = .none record.autoRetouchClientRequestId = "" } persist(record) } 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.session.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 persist(_ record: TravelAlbumOTGPhotoRecord) { var updated = record updated.updatedAt = Int64(Date().timeIntervalSince1970 * 1000) persistedRecordsById[updated.id] = updated storage.upsert(updated, albumId: albumId) applyMergedPhotos() } private func notifyStateChanged() { if Thread.isMainThread { onStateChange?() } else { DispatchQueue.main.async { [weak self] in self?.onStateChange?() } } } private static func persistedTransferMode(in userDefaults: UserDefaults) -> TravelAlbumOTGTransferMode { guard let rawValue = userDefaults.string(forKey: transferModeDefaultsKey), let mode = TravelAlbumOTGTransferMode(rawValue: rawValue) else { return .liveUpload } return mode } private static func currentPhoneModelName() -> String { let identifier = machineIdentifier() if identifier == "i386" || identifier == "x86_64" || identifier == "arm64" { return "iPhone 模拟器" } if let modelName = iPhoneModelNames[identifier] { return modelName } return identifier.hasPrefix("iPhone") ? identifier : "iPhone" } private static func machineIdentifier() -> String { var systemInfo = utsname() uname(&systemInfo) return withUnsafePointer(to: &systemInfo.machine) { pointer in pointer.withMemoryRebound(to: CChar.self, capacity: 1) { String(validatingUTF8: $0) ?? "iPhone" } } } private static func availableStorageText() -> String { guard let bytes = availableStorageBytes() else { return "-- GB 可用" } let gigabytes = Double(bytes) / 1_073_741_824.0 if gigabytes >= 100 { return "\(Int(gigabytes.rounded(.down))) GB 可用" } return String(format: "%.1f GB 可用", gigabytes) } private static func availableStorageBytes() -> Int64? { let homeURL = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) if let values = try? homeURL.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]), let capacity = values.volumeAvailableCapacityForImportantUsage { return capacity } if let attributes = try? FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory()), let freeSize = attributes[.systemFreeSize] as? NSNumber { return freeSize.int64Value } return nil } private static let iPhoneModelNames: [String: String] = [ "iPhone12,1": "iPhone 11", "iPhone12,3": "iPhone 11 Pro", "iPhone12,5": "iPhone 11 Pro Max", "iPhone12,8": "iPhone SE 2", "iPhone13,1": "iPhone 12 mini", "iPhone13,2": "iPhone 12", "iPhone13,3": "iPhone 12 Pro", "iPhone13,4": "iPhone 12 Pro Max", "iPhone14,2": "iPhone 13 Pro", "iPhone14,3": "iPhone 13 Pro Max", "iPhone14,4": "iPhone 13 mini", "iPhone14,5": "iPhone 13", "iPhone14,6": "iPhone SE 3", "iPhone14,7": "iPhone 14", "iPhone14,8": "iPhone 14 Plus", "iPhone15,2": "iPhone 14 Pro", "iPhone15,3": "iPhone 14 Pro Max", "iPhone15,4": "iPhone 15", "iPhone15,5": "iPhone 15 Plus", "iPhone16,1": "iPhone 15 Pro", "iPhone16,2": "iPhone 15 Pro Max", "iPhone17,1": "iPhone 16 Pro", "iPhone17,2": "iPhone 16 Pro Max", "iPhone17,3": "iPhone 16", "iPhone17,4": "iPhone 16 Plus", "iPhone17,5": "iPhone 16e", ] 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 record: TravelAlbumOTGPhotoRecord) { guard record.albumId == albumId else { return } handleNewlyImportedPhotoIds([record.id]) onLiveShotSaved?(record.fileName) } }