440 lines
14 KiB
Swift
440 lines
14 KiB
Swift
//
|
||
// 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 deletedPhotoIds: Set<String> = []
|
||
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()
|
||
}
|
||
|
||
/// 主动断开相机连接。
|
||
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
|
||
}
|
||
|
||
/// 选择或取消选择照片。
|
||
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)
|
||
deletedPhotoIds.insert(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() {
|
||
deletedPhotoIds = storage.loadDeletedIds(albumId: albumId)
|
||
let records = storage.load(albumId: albumId)
|
||
persistedRecordsById = Dictionary(uniqueKeysWithValues: records.map { ($0.id, $0) })
|
||
applyMergedPhotos()
|
||
}
|
||
|
||
private func applyMergedPhotos() {
|
||
let persistedItems = persistedRecordsById.values
|
||
.filter { !deletedPhotoIds.contains($0.id) }
|
||
.map { $0.toPhotoItem() }
|
||
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,
|
||
FileManager.default.fileExists(atPath: record.localPath) {
|
||
return record
|
||
}
|
||
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 ? item.thumbnailURL?.path ?? "" : "",
|
||
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)
|
||
}
|
||
}
|