将 otg_swift OTG 引擎迁入 Core/CameraOTG,并接入旅拍有线传图与历史导入。
恢复 USB 相机连接、边拍边传、三品牌驱动与 SwiftUI 历史导入页,通过适配层对接现有本地上传与 OSS 管道。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -0,0 +1,220 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// 按日分组的相机照片区块。
|
||||
struct CameraPhotoSection: Equatable, Identifiable {
|
||||
var id: String { dayKey }
|
||||
let dayKey: String
|
||||
let title: String
|
||||
let photos: [CameraObject]
|
||||
}
|
||||
|
||||
/// 相机历史导入页状态机。
|
||||
enum CameraImportState: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case loaded(sections: [CameraPhotoSection])
|
||||
case importing(current: Int, total: Int)
|
||||
case finished(importedCount: Int)
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
/// 相机 SD 卡历史照片多选导入 ViewModel。
|
||||
@MainActor
|
||||
final class CameraHistoryImportViewModel: ObservableObject {
|
||||
@Published private(set) var state: CameraImportState = .idle
|
||||
@Published private(set) var selectedPhotoIDs: Set<String> = []
|
||||
@Published private(set) var shouldShowSonyMTPHelp = false
|
||||
@Published private(set) var sections: [CameraPhotoSection] = []
|
||||
|
||||
let albumId: Int
|
||||
let accountKey: String
|
||||
private weak var driver: (any CameraDriver)?
|
||||
private let photoSink: OTGPhotoSink
|
||||
private let onImported: () -> Void
|
||||
|
||||
private var allPhotos: [CameraObject] = []
|
||||
private var existingAlbumPhotoIDs: Set<String> = []
|
||||
private var thumbnailCache: [String: Data] = [:]
|
||||
|
||||
/// 初始化历史导入 ViewModel。
|
||||
init(
|
||||
albumId: Int,
|
||||
accountKey: String,
|
||||
driver: any CameraDriver,
|
||||
photoSink: OTGPhotoSink,
|
||||
onImported: @escaping () -> Void
|
||||
) {
|
||||
self.albumId = albumId
|
||||
self.accountKey = accountKey
|
||||
self.driver = driver
|
||||
self.photoSink = photoSink
|
||||
self.onImported = onImported
|
||||
}
|
||||
|
||||
/// 通过 Driver 拉取相机内照片并按日分组。
|
||||
func loadPhotos() {
|
||||
guard let driver else {
|
||||
state = .failed(message: "相机未连接")
|
||||
return
|
||||
}
|
||||
|
||||
state = .loading
|
||||
|
||||
Task {
|
||||
do {
|
||||
existingAlbumPhotoIDs = await photoSink.existingCameraObjectIDs()
|
||||
let objects = try await driver.listObjects()
|
||||
allPhotos = objects
|
||||
let sections = Self.groupByDay(objects)
|
||||
if sections.isEmpty {
|
||||
self.sections = []
|
||||
state = .failed(message: "相机中没有可导入的照片")
|
||||
} else {
|
||||
self.sections = sections
|
||||
state = .loaded(sections: sections)
|
||||
}
|
||||
updateSonyMTPHelpVisibility()
|
||||
} catch {
|
||||
state = .failed(message: error.localizedDescription)
|
||||
updateSonyMTPHelpVisibility()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断照片是否已在当前相册。
|
||||
func isPhotoAlreadyInAlbum(id: String) -> Bool {
|
||||
existingAlbumPhotoIDs.contains(id)
|
||||
}
|
||||
|
||||
/// 切换单张照片选中状态。
|
||||
func togglePhoto(id: String) {
|
||||
guard !isPhotoAlreadyInAlbum(id: id) else { return }
|
||||
if selectedPhotoIDs.contains(id) {
|
||||
selectedPhotoIDs.remove(id)
|
||||
} else {
|
||||
selectedPhotoIDs.insert(id)
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换分组全选。
|
||||
func toggleSection(_ section: CameraPhotoSection) {
|
||||
let importableIDs = Set(section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.map(\.id))
|
||||
guard !importableIDs.isEmpty else { return }
|
||||
if importableIDs.isSubset(of: selectedPhotoIDs) {
|
||||
selectedPhotoIDs.subtract(importableIDs)
|
||||
} else {
|
||||
selectedPhotoIDs.formUnion(importableIDs)
|
||||
}
|
||||
}
|
||||
|
||||
func isPhotoSelected(id: String) -> Bool {
|
||||
selectedPhotoIDs.contains(id)
|
||||
}
|
||||
|
||||
func isSectionFullySelected(_ section: CameraPhotoSection) -> Bool {
|
||||
let ids = section.photos.filter { !isPhotoAlreadyInAlbum(id: $0.id) }.map(\.id)
|
||||
return !ids.isEmpty && ids.allSatisfy { selectedPhotoIDs.contains($0) }
|
||||
}
|
||||
|
||||
func hasImportablePhotos(in section: CameraPhotoSection) -> Bool {
|
||||
section.photos.contains { !isPhotoAlreadyInAlbum(id: $0.id) }
|
||||
}
|
||||
|
||||
/// 加载相机缩略图。
|
||||
func thumbnailData(for photo: CameraObject, maxPixelSize: Int) async -> Data? {
|
||||
if let cached = thumbnailCache[photo.id] {
|
||||
return cached
|
||||
}
|
||||
guard let driver else { return nil }
|
||||
guard let data = await driver.requestThumbnailData(for: photo, maxPixelSize: maxPixelSize) else {
|
||||
return nil
|
||||
}
|
||||
thumbnailCache[photo.id] = data
|
||||
return data
|
||||
}
|
||||
|
||||
/// 顺序下载选中项并写入本地存储。
|
||||
func importSelected() {
|
||||
guard let driver else { return }
|
||||
let selected = allPhotos.filter {
|
||||
selectedPhotoIDs.contains($0.id) && !isPhotoAlreadyInAlbum(id: $0.id)
|
||||
}
|
||||
guard !selected.isEmpty else { return }
|
||||
|
||||
state = .importing(current: 0, total: selected.count)
|
||||
|
||||
Task {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("camera-history-import", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
var imported = 0
|
||||
|
||||
for (index, object) in selected.enumerated() {
|
||||
do {
|
||||
let downloadedURL = try await driver.downloadObject(object, to: tempDir)
|
||||
_ = try await photoSink.saveDownloadedFile(
|
||||
at: downloadedURL,
|
||||
filename: object.filename,
|
||||
capturedAt: object.capturedAt,
|
||||
cameraObjectID: object.id
|
||||
)
|
||||
imported += 1
|
||||
state = .importing(current: index + 1, total: selected.count)
|
||||
} catch {
|
||||
OTGLog.error(.connection, "import failed: \(object.filename) \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
selectedPhotoIDs.removeAll()
|
||||
state = .finished(importedCount: imported)
|
||||
onImported()
|
||||
}
|
||||
}
|
||||
|
||||
private func updateSonyMTPHelpVisibility() {
|
||||
guard driver?.platform == .sony else {
|
||||
shouldShowSonyMTPHelp = false
|
||||
return
|
||||
}
|
||||
switch state {
|
||||
case .loaded(let sections):
|
||||
shouldShowSonyMTPHelp = sections.isEmpty
|
||||
case .failed:
|
||||
shouldShowSonyMTPHelp = allPhotos.isEmpty
|
||||
default:
|
||||
shouldShowSonyMTPHelp = false
|
||||
}
|
||||
}
|
||||
|
||||
private static func groupByDay(_ photos: [CameraObject]) -> [CameraPhotoSection] {
|
||||
let calendar = Calendar.current
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "zh_CN")
|
||||
formatter.dateFormat = "yyyy年M月d日"
|
||||
|
||||
let grouped = Dictionary(grouping: photos) { photo in
|
||||
calendar.startOfDay(for: photo.capturedAt)
|
||||
}
|
||||
|
||||
return grouped.keys
|
||||
.sorted(by: >)
|
||||
.map { day in
|
||||
let items = grouped[day]!.sorted { $0.capturedAt > $1.capturedAt }
|
||||
let dayKey = dayKeyString(for: day, calendar: calendar)
|
||||
let title = sectionTitle(for: day, formatter: formatter, calendar: calendar)
|
||||
return CameraPhotoSection(dayKey: dayKey, title: title, photos: items)
|
||||
}
|
||||
}
|
||||
|
||||
private static func dayKeyString(for day: Date, calendar: Calendar) -> String {
|
||||
let components = calendar.dateComponents([.year, .month, .day], from: day)
|
||||
return String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0)
|
||||
}
|
||||
|
||||
private static func sectionTitle(for day: Date, formatter: DateFormatter, calendar: Calendar) -> String {
|
||||
if calendar.isDateInToday(day) { return "今天" }
|
||||
if calendar.isDateInYesterday(day) { return "昨天" }
|
||||
return formatter.string(from: day)
|
||||
}
|
||||
}
|
||||
@ -434,6 +434,9 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
@Published var showDeviceStorageAlert = false
|
||||
@Published var showCameraDisconnectedAlert = false
|
||||
@Published var showHelpDoc = false
|
||||
@Published var showHistoryImport = false
|
||||
@Published var showDisconnectCamera = false
|
||||
@Published private(set) var isContentCatalogReady = false
|
||||
@Published var isRefreshingCameraFiles = false
|
||||
@Published var isSearchingCamera = false
|
||||
@Published var deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot()
|
||||
@ -442,6 +445,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
|
||||
let context: WiredTransferContext
|
||||
private let pipeline = WiredCameraTransferPipeline()
|
||||
private lazy var otgCoordinator = OTGLiveTransferCoordinator(pipeline: pipeline)
|
||||
private var photoStore: WiredTransferPhotoStore?
|
||||
private var userIDProvider: (() -> String)?
|
||||
private var downloadScope: CameraDownloadStorage.Scope?
|
||||
@ -502,7 +506,21 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: currentDownloadScope)
|
||||
refreshDeviceStorageInfo()
|
||||
loadPersistedPhotos()
|
||||
connectionState = .disconnected
|
||||
|
||||
bindOTGCoordinator()
|
||||
otgCoordinator.start(
|
||||
albumId: context.albumId,
|
||||
accountKey: accountCachePrefix,
|
||||
autoUploadProvider: { [weak self] in
|
||||
self?.transferModeOption == Self.modeLiveCapture
|
||||
},
|
||||
existingObjectIDsProvider: { [weak self] in
|
||||
await self?.existingCameraObjectIDs() ?? []
|
||||
}
|
||||
)
|
||||
connectionState = otgCoordinator.connectionState
|
||||
isContentCatalogReady = otgCoordinator.isContentCatalogReady
|
||||
wasConnected = connectionState.isConnected
|
||||
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
@ -510,6 +528,37 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
detachFromUI()
|
||||
}
|
||||
|
||||
private func bindOTGCoordinator() {
|
||||
otgCoordinator.onConnectionStateChange = { [weak self] state in
|
||||
self?.handleConnectionStateChange(state)
|
||||
}
|
||||
otgCoordinator.onContentCatalogReady = { [weak self] in
|
||||
self?.isContentCatalogReady = true
|
||||
}
|
||||
otgCoordinator.onLiveShotSaved = { [weak self] (_: SavedOTGPhoto) in
|
||||
self?.refreshDeviceStorageInfo()
|
||||
}
|
||||
otgCoordinator.onError = { [weak self] message in
|
||||
self?.errorMessage = message
|
||||
}
|
||||
otgCoordinator.onSuggestReplug = { [weak self] in
|
||||
self?.errorMessage = "长时间未找到相机,请尝试重新插拔连接线"
|
||||
}
|
||||
}
|
||||
|
||||
private func existingCameraObjectIDs() async -> Set<String> {
|
||||
var ids = Set<String>()
|
||||
for record in persistedRecordsByID.values {
|
||||
if let localURL = CameraDownloadStorage.resolveLocalURL(from: record.localPath),
|
||||
let objectID = ICCameraItemScanner.cameraObjectID(localPath: localURL.path) {
|
||||
ids.insert(objectID)
|
||||
} else {
|
||||
ids.insert(record.id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
/// 页面离开:仅 detach UI 回调,保持相机连接。
|
||||
func stop() async {
|
||||
detachFromUI()
|
||||
@ -525,6 +574,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
disconnectAlertTask?.cancel()
|
||||
showCameraDisconnectedAlert = false
|
||||
pipeline.detachFromUI()
|
||||
otgCoordinator.detach()
|
||||
}
|
||||
|
||||
/// 刷新设备存储信息。
|
||||
@ -582,20 +632,98 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 刷新相机文件列表(OTG 能力已移除,仅刷新本地持久化记录)。
|
||||
/// 刷新相机文件列表;catalog 就绪时同步相机内已有照片(拍后传输模式)。
|
||||
func refreshCameraFiles() async {
|
||||
guard !isRefreshingCameraFiles else { return }
|
||||
isRefreshingCameraFiles = true
|
||||
defer { isRefreshingCameraFiles = false }
|
||||
|
||||
if connectionState.isConnected, isContentCatalogReady, let driver = otgCoordinator.currentDriver {
|
||||
do {
|
||||
let objects = try await driver.listObjects()
|
||||
for object in objects {
|
||||
guard !deletedPhotoIDs.contains(object.id) else { continue }
|
||||
guard !persistedRecordsByID.keys.contains(object.id) else { continue }
|
||||
pipeline.markDownloading(assetID: object.id, filename: object.filename)
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("camera-sync", isDirectory: true)
|
||||
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
let downloadedURL = try await driver.downloadObject(object, to: tempDir)
|
||||
if let scope = downloadScope,
|
||||
let sink = makeTransientPhotoSink(scope: scope) {
|
||||
let saved = try await sink.saveDownloadedFile(
|
||||
at: downloadedURL,
|
||||
filename: object.filename,
|
||||
capturedAt: object.capturedAt,
|
||||
cameraObjectID: object.id
|
||||
)
|
||||
pipeline.ingestDownloadedAsset(
|
||||
assetID: saved.assetID,
|
||||
filename: saved.filename,
|
||||
localPath: saved.relativeLocalPath,
|
||||
capturedAt: saved.capturedAt,
|
||||
autoUpload: false
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
loadPersistedPhotos()
|
||||
}
|
||||
|
||||
/// 重新搜索相机(OTG 能力已移除,保持未连接状态)。
|
||||
/// 重新搜索 USB 相机。
|
||||
func restartCameraSearch() async {
|
||||
guard !isSearchingCamera else { return }
|
||||
guard !connectionState.isConnected, !isSearchingCamera else { return }
|
||||
isSearchingCamera = true
|
||||
defer { isSearchingCamera = false }
|
||||
otgCoordinator.restartSearch()
|
||||
connectionState = otgCoordinator.connectionState
|
||||
}
|
||||
|
||||
/// 断开相机连接。
|
||||
func disconnectCamera() {
|
||||
otgCoordinator.disconnect()
|
||||
connectionState = .disconnected
|
||||
isContentCatalogReady = false
|
||||
wasConnected = false
|
||||
}
|
||||
|
||||
/// 打开历史导入页。
|
||||
func onHistoryImportClick() {
|
||||
guard connectionState.isConnected, isContentCatalogReady else {
|
||||
errorMessage = "请等待相机连接完成后再导入"
|
||||
return
|
||||
}
|
||||
showHistoryImport = true
|
||||
}
|
||||
|
||||
/// 创建历史导入 ViewModel。
|
||||
func makeHistoryImportViewModel(accountKey: String) -> CameraHistoryImportViewModel? {
|
||||
otgCoordinator.makeHistoryImportViewModel(
|
||||
albumId: context.albumId,
|
||||
accountKey: accountKey,
|
||||
onImported: { [weak self] in
|
||||
self?.loadPersistedPhotos()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
private func makeTransientPhotoSink(scope: CameraDownloadStorage.Scope) -> TravelAlbumOTGPhotoSink? {
|
||||
TravelAlbumOTGPhotoSink(
|
||||
albumId: context.albumId,
|
||||
accountKey: scope.accountKey,
|
||||
pipeline: pipeline,
|
||||
existingObjectIDsProvider: { [weak self] in
|
||||
await self?.existingCameraObjectIDs() ?? []
|
||||
},
|
||||
autoUploadProvider: { [weak self] in
|
||||
self?.transferModeOption == Self.modeLiveCapture
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// 批量上传按钮点击(三态:进入勾选 / 上传选中 / 取消选择)。
|
||||
|
||||
Reference in New Issue
Block a user