将 otg_swift OTG 引擎迁入 Core/CameraOTG,并接入旅拍有线传图与历史导入。

恢复 USB 相机连接、边拍边传、三品牌驱动与 SwiftUI 历史导入页,通过适配层对接现有本地上传与 OSS 管道。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-06 11:22:07 +08:00
parent d7fec35715
commit be9c844bd3
50 changed files with 5721 additions and 20 deletions

View File

@ -1,6 +1,6 @@
import Foundation
/// 线UI 使OTG
/// 线UI 使
enum CameraConnectionState: Equatable {
case disconnected
case searching

View File

@ -0,0 +1,41 @@
import Foundation
/// MTP
struct SonyMTPHelpSection: Equatable, Identifiable {
var id: String { title }
let title: String
let body: String
}
/// MTP
enum SonyMTPHelpContent {
static let navigationTitle = "索尼 MTP 导入说明"
static let introduction = """
当前可能处于「电脑遥控PC Remote」等 USB 模式App 无法读取 SD 卡历史照片。\
若要使用「历史导入」浏览相机内已有照片,请先将相机切换为 MTP 模式。
"""
static let sections: [SonyMTPHelpSection] = [
SonyMTPHelpSection(
title: "1. USB 连接模式",
body: "进入相机菜单:设置 → USB 连接 → 选择「MTP」或「自动」优先 MTP"
),
SonyMTPHelpSection(
title: "2. 关闭遥控连接",
body: "关闭:网络 → 电脑遥控、智能手机连接。避免相机仍处于 PC Remote 传图状态。"
),
SonyMTPHelpSection(
title: "3. 保存目的地",
body: "静态影像保存目的地勿选「仅电脑」;建议「仅拍摄装置」或「电脑+拍摄装置」。"
),
SonyMTPHelpSection(
title: "4. 重新连接",
body: "修改设置后,拔插 USB 线,回到 App 重新连接相机,再点击「历史导入」。"
),
SonyMTPHelpSection(
title: "5. 说明",
body: "MTP 连接期间机身可能无法按快门。边拍边传请使用 PC Remote 模式,历史导入请使用 MTP 模式。"
)
]
}

View File

@ -0,0 +1,20 @@
import Foundation
/// OTG 线
extension ConnectionState {
/// 线 UI 使
var cameraConnectionState: CameraConnectionState {
switch self {
case .idle, .disconnected:
return .disconnected
case .searching:
return .searching
case .connecting:
return .connecting
case .connected(_, let deviceName):
return .connected(deviceName: deviceName)
case .failed(let message):
return .error(message)
}
}
}

View File

@ -0,0 +1,129 @@
import Foundation
/// OTG 线
@MainActor
final class OTGLiveTransferCoordinator: NSObject, ConnectionManagerDelegate {
var onConnectionStateChange: ((CameraConnectionState) -> Void)?
var onContentCatalogReady: (() -> Void)?
var onLiveShotSaved: ((SavedOTGPhoto) -> Void)?
var onError: ((String) -> Void)?
var onSuggestReplug: (() -> Void)?
private(set) var currentDriver: (any CameraDriver)?
private(set) var isContentCatalogReady = false
private let connectionManager: ConnectionManager
private let pipeline: WiredCameraTransferPipeline
private var photoSink: TravelAlbumOTGPhotoSink?
private var liveContext: OTGLiveTransferContext?
///
init(
connectionManager: ConnectionManager = .shared,
pipeline: WiredCameraTransferPipeline
) {
self.connectionManager = connectionManager
self.pipeline = pipeline
super.init()
}
/// ConnectionManager
func start(
albumId: Int,
accountKey: String,
autoUploadProvider: @escaping () -> Bool,
existingObjectIDsProvider: @escaping () async -> Set<String>
) {
let sink = TravelAlbumOTGPhotoSink(
albumId: albumId,
accountKey: accountKey,
pipeline: pipeline,
existingObjectIDsProvider: existingObjectIDsProvider,
autoUploadProvider: autoUploadProvider
)
photoSink = sink
liveContext = OTGLiveTransferContext(albumId: albumId, accountKey: accountKey, photoSink: sink)
connectionManager.configureLiveTransfer(context: liveContext)
connectionManager.delegate = self
syncFromManager()
connectionManager.start()
}
/// UI
func detach() {
connectionManager.unbindDelegate()
connectionManager.delegate = nil
}
///
func disconnect() {
connectionManager.disconnect()
liveContext = nil
photoSink = nil
}
/// USB
func restartSearch() {
connectionManager.start()
syncFromManager()
}
/// UI
var connectionState: CameraConnectionState {
connectionManager.state.cameraConnectionState
}
/// ViewModel
func makeHistoryImportViewModel(
albumId: Int,
accountKey: String,
onImported: @escaping () -> Void
) -> CameraHistoryImportViewModel? {
guard let driver = currentDriver, isContentCatalogReady, let photoSink else { return nil }
return CameraHistoryImportViewModel(
albumId: albumId,
accountKey: accountKey,
driver: driver,
photoSink: photoSink,
onImported: onImported
)
}
private func syncFromManager() {
currentDriver = connectionManager.currentDriver
isContentCatalogReady = connectionManager.isContentCatalogReady
onConnectionStateChange?(connectionManager.state.cameraConnectionState)
if isContentCatalogReady {
onContentCatalogReady?()
}
}
// MARK: - ConnectionManagerDelegate
func connectionManager(_ manager: ConnectionManager, didUpdate state: ConnectionState) {
onConnectionStateChange?(state.cameraConnectionState)
}
func connectionManager(_ manager: ConnectionManager, didCreate driver: any CameraDriver) {
currentDriver = driver
}
func connectionManager(_ manager: ConnectionManager, didFail error: any Error) {
onError?(error.localizedDescription)
}
func connectionManager(_ manager: ConnectionManager, contentCatalogDidBecomeReady driver: any CameraDriver) {
currentDriver = driver
isContentCatalogReady = true
onContentCatalogReady?()
}
func connectionManagerDidSuggestReplug(_ manager: ConnectionManager) {
onSuggestReplug?()
}
func connectionManager(_ manager: ConnectionManager, didSaveLiveShot photo: SavedOTGPhoto, albumId: Int) {
onLiveShotSaved?(photo)
}
}

View File

@ -0,0 +1,96 @@
import Foundation
/// OTG scoped
@MainActor
final class TravelAlbumOTGPhotoSink: OTGPhotoSink {
private let albumId: Int
private let accountKey: String
private let pipeline: WiredCameraTransferPipeline
private let existingObjectIDsProvider: () async -> Set<String>
private let autoUploadProvider: () -> Bool
/// OTG
init(
albumId: Int,
accountKey: String,
pipeline: WiredCameraTransferPipeline,
existingObjectIDsProvider: @escaping () async -> Set<String>,
autoUploadProvider: @escaping () -> Bool
) {
self.albumId = albumId
self.accountKey = accountKey
self.pipeline = pipeline
self.existingObjectIDsProvider = existingObjectIDsProvider
self.autoUploadProvider = autoUploadProvider
}
///
func saveLiveShot(
data: Data,
filename: String,
capturedAt: Date,
cameraObjectID: String
) async throws -> SavedOTGPhoto {
let fileURL = try OTGPhotoFileWriter.writeImage(
data,
filename: filename,
accountKey: accountKey,
albumID: albumId
)
return ingestSavedFile(
fileURL: fileURL,
filename: filename,
capturedAt: capturedAt,
cameraObjectID: cameraObjectID
)
}
///
func saveDownloadedFile(
at url: URL,
filename: String,
capturedAt: Date,
cameraObjectID: String
) async throws -> SavedOTGPhoto {
let fileURL = try OTGPhotoFileWriter.moveDownloadedFile(
from: url,
filename: filename,
accountKey: accountKey,
albumID: albumId
)
return ingestSavedFile(
fileURL: fileURL,
filename: filename,
capturedAt: capturedAt,
cameraObjectID: cameraObjectID
)
}
/// ID
func existingCameraObjectIDs() async -> Set<String> {
await existingObjectIDsProvider()
}
private func ingestSavedFile(
fileURL: URL,
filename: String,
capturedAt: Date,
cameraObjectID: String
) -> SavedOTGPhoto {
let relativePath = OTGPhotoFileWriter.relativePath(for: fileURL)
let autoUpload = autoUploadProvider()
pipeline.ingestDownloadedAsset(
assetID: cameraObjectID,
filename: filename,
localPath: relativePath,
capturedAt: capturedAt,
autoUpload: autoUpload
)
return SavedOTGPhoto(
assetID: cameraObjectID,
relativeLocalPath: relativePath,
filename: filename,
capturedAt: capturedAt
)
}
}

View File

@ -142,6 +142,96 @@ final class WiredCameraTransferPipeline {
lastNotifiedProgressByAssetID.removeValue(forKey: assetID)
}
///
func ingestDownloadedAsset(
assetID: String,
filename: String,
localPath: String,
capturedAt: Date?,
autoUpload: Bool
) {
guard !excludedAssetIDs.contains(assetID) else { return }
let capturedAtString = capturedAt.map { CameraTransferTask.capturedAtString(from: $0) }
?? CameraTransferTask.capturedAtString(forLocalPath: localPath)
if let index = tasks.firstIndex(where: { $0.assetID == assetID }) {
tasks[index].localPath = localPath
if tasks[index].capturedAt?.isEmpty != false {
tasks[index].capturedAt = capturedAtString
}
if tasks[index].status != .uploaded {
tasks[index].status = .downloaded
}
tasks[index].errorMessage = nil
} else {
tasks.insert(
CameraTransferTask(
assetID: assetID,
filename: filename,
localPath: localPath,
capturedAt: capturedAtString,
status: .downloaded
),
at: 0
)
}
if autoUpload, uploadEnabled, uploadSink != nil {
autoUploadEligibleAssetIDs.insert(assetID)
processUploadQueue()
}
notify()
}
/// asset
func markDownloading(assetID: String, filename: String) {
guard !excludedAssetIDs.contains(assetID) else { return }
if let index = tasks.firstIndex(where: { $0.assetID == assetID }) {
tasks[index].status = .downloading
tasks[index].errorMessage = nil
} else {
tasks.insert(
CameraTransferTask(assetID: assetID, filename: filename, status: .downloading),
at: 0
)
}
notify()
}
/// asset
func failDownload(assetID: String, message: String) {
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }
tasks[index].status = .failed
tasks[index].errorMessage = message
notify()
}
///
private func processUploadQueue() {
guard uploadSink != nil else { return }
while activeUploadAssetIDs.count < maxConcurrentUploads,
let task = tasks.first(where: {
$0.status == .downloaded
&& !activeUploadAssetIDs.contains($0.assetID)
&& autoUploadEligibleAssetIDs.contains($0.assetID)
}) {
startConcurrentUpload(assetID: task.assetID)
}
}
private func startConcurrentUpload(assetID: String) {
guard !activeUploadAssetIDs.contains(assetID) else { return }
activeUploadAssetIDs.insert(assetID)
Task { @MainActor in
await self.uploadTask(assetID: assetID)
self.activeUploadAssetIDs.remove(assetID)
self.processUploadQueue()
}
}
private func uploadAssetIfNeeded(assetID: String) async {
if activeUploadAssetIDs.contains(assetID) { return }
guard let index = tasks.firstIndex(where: { $0.assetID == assetID }) else { return }

View File

@ -47,32 +47,50 @@ WiredTransferHeaderView # 蓝色顶栏:相册名 + 手机号
WiredTransferControlCard # 浮动白卡片:设备存储 / 连接状态 / 筛选 Chip / Tab
WiredTransferSelectAllBar # 勾选模式全选栏(条件显示)
WiredTransferPhotoListPanel # 时间侧栏 + 30 分钟分组列表
WiredTransferBottomBar # 批量上传 + 指定上传
WiredTransferBottomBar # 批量上传 + 指定上传 + 历史导入
SpecifyUploadBottomSheet # 指定上传选项弹层
CameraHistoryImportView # 相机 SD 卡历史导入全屏 Sheet
SonyMTPHelpView # Sony MTP 模式帮助说明
```
## 有线传图交互流程
1. **刷新**:顶部连接区「刷新」按钮 → `refreshCameraFiles()` → 刷新本地持久化记录
2. **批量上传三态**
1. **USB 连接**:进入页面后 `OTGLiveTransferCoordinator` 驱动 `Core/CameraOTG``ConnectionManager` 搜索并连接 Sony / Canon / Nikon 相机;连接成功 Toast 提示,断开时弹窗警告
2. **刷新**catalog 就绪后点击「刷新」→ `refreshCameraFiles()` 同步相机内已有照片(拍后传输模式仅下载不上传);未连接时刷新本地持久化记录
3. **历史导入**:底部「历史导入」在 `catalogReady && 已连接` 时可点,打开 `CameraHistoryImportView` 按日多选导入Sony catalog 为空时展示 MTP 帮助入口
4. **批量上传三态**
- 首次点击 → 进入勾选模式
- 有选中项 → 上传选中照片
- 无选中项 → 取消选择
3. **指定上传**:底部 Sheet 选择「上传所有未上传的照片」或「上传所有今日拍摄的照片」
5. **指定上传**:底部 Sheet 选择「上传所有未上传的照片」或「上传所有今日拍摄的照片」
- 指定上传会同时处理当前管道任务和本地持久化记录;仅存在本地记录的照片会先按本地路径恢复到传输管道,再上传 OSS 并登记素材
4. **时间侧栏**:按 30 分钟时间段分组,点击侧栏滚动到对应 section
5. **格式 Chip**:当前固定展示 JPG与「不修图」一样不可点击RAW 等格式后续扩展
6. **传输模式**:「边拍边传 / 拍后传输」按账号缓存,下次进入有线传图页自动恢复
- 「边拍边传」只自动上传该模式下由相机新照片事件发现的照片
- 「拍后传输」下载到 App 的照片切换模式后不会自动传,需通过批量上传、指定上传、勾选上传或单张重试显式上传
6. **时间侧栏**:按 30 分钟时间段分组,点击侧栏滚动到对应 section
7. **格式 Chip**:当前固定展示 JPG与「不修图」一样不可点击RAW 等格式后续扩展
8. **传输模式**:「边拍边传 / 拍后传输」按账号缓存,下次进入有线传图页自动恢复
- 「边拍边传」相机新照片经 OTG 落盘后自动进入上传队列(最多 3 张并发)
- 「拍后传输」下载到 App 的照片自动传,需通过批量上传、指定上传、勾选上传或单张重试显式上传
9. **前后台**App 回到前台且未连接时自动重新搜索相机;离开页面仅 detach UI保持设备缓存与 demo 一致)
## OTG 与上传管道
```
WiredCameraTransferView → WiredCameraTransferViewModel
→ OTGLiveTransferCoordinator → ConnectionManager (Core/CameraOTG)
→ TravelAlbumOTGPhotoSink → WiredCameraTransferPipeline → OSS 上传
CameraHistoryImportView → CameraHistoryImportViewModel → CameraDriver + OTGPhotoSink
```
- OTG 引擎详见 `Core/CameraOTG/CameraOTG.md`
- 边拍边传路径:`ConnectionManager` live shot → `OTGPhotoSink``pipeline.ingestDownloadedAsset(autoUpload:)``TravelAlbumMaterialUploader`
- 历史导入路径:批量下载落盘,不自动上传,导入完成后刷新主列表
## 业务流程
1. 创建任务:`POST .../travel-album/create`
2. 进入有线传图:有线传图 UI 页USB/OTG 相机连接能力已移除,保留页面与本地照片上传)
2. 进入有线传图:USB 连接相机,边拍边传或拍后传输 / 历史导入
3. OSS 上传:复用 `OSSUploadService.uploadAlbumFile`
4. 素材登记:`POST .../travel-album/upload-material`
5. 本地照片路径以账号 + 相册 scoped 目录持久化(`CameraDownloads/<accountKey>/<albumID>/originals/xxx.JPG`,该功能未上线,不兼容旧版全局目录记录
5. 本地照片路径以账号 + 相册 scoped 目录持久化(`CameraDownloads/<accountKey>/<albumID>/originals/xxx.JPG`
## 有线传图性能策略
@ -85,16 +103,17 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
- `WiredCameraTransferPipeline` 只依赖 `CameraAssetUploadSink` 协议
- `TravelAlbumMaterialUploader` 实现 OSS + 后端登记
- `OTGLiveTransferCoordinator` 桥接 `Core/CameraOTG` 与旅拍上传管道,通过 `OTGPhotoSink` 注入存储
- 与「相册管理」(`Features/Assets`API、Store、路由完全隔离
## 依赖模块
- `Features/TravelAlbum/Services` — 本地存储、上传编排
- `Core/CameraOTG` — USB 连接、品牌 Driver、边拍边传、PTP
- `Features/TravelAlbum/Services` — 本地存储、上传编排、OTG 协调器
- `Core/Upload` — OSS STS 上传
## 二期待补齐
- RAW 预览
- USB/OTG 相机有线连接与边拍边传
- 格式 Chip 扩展 RAW / JPEG+RAW并与 `WiredCameraTransferPipeline` 联动
- 设备存储不足时的业务侧限制(当前仅 UI 提示弹窗)

View File

@ -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)
}
}

View File

@ -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
}
)
}
/// / /

View File

@ -0,0 +1,61 @@
import SwiftUI
///
struct CameraHistoryImportPhotoCell: View {
let photo: CameraObject
let isImported: Bool
let isSelected: Bool
let thumbnailProvider: () async -> Data?
let onToggle: () -> Void
@State private var thumbnail: UIImage?
var body: some View {
ZStack(alignment: .topTrailing) {
Group {
if let thumbnail {
Image(uiImage: thumbnail)
.resizable()
.scaledToFill()
} else {
Color.gray.opacity(0.15)
.overlay {
ProgressView()
.scaleEffect(0.7)
}
}
}
.frame(maxWidth: .infinity)
.aspectRatio(1, contentMode: .fit)
.clipped()
if isImported {
Text("已导入")
.font(.system(size: 10, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 3)
.background(Color.black.opacity(0.55))
.clipShape(RoundedRectangle(cornerRadius: 4))
.padding(6)
} else {
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.system(size: 22))
.foregroundStyle(isSelected ? AppDesign.primary : .white)
.shadow(radius: 2)
.padding(6)
}
}
.contentShape(Rectangle())
.onTapGesture {
guard !isImported else { return }
onToggle()
}
.task(id: photo.id) {
guard thumbnail == nil else { return }
if let data = await thumbnailProvider(), let image = UIImage(data: data) {
thumbnail = image
}
}
}
}

View File

@ -0,0 +1,28 @@
import SwiftUI
///
struct CameraHistoryImportSectionHeader: View {
let title: String
let isFullySelected: Bool
let hasImportable: Bool
let onToggleAll: () -> Void
var body: some View {
HStack {
Text(title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(WiredTransferDesign.text333)
Spacer()
if hasImportable {
Button(isFullySelected ? "取消全选" : "全选") {
onToggleAll()
}
.font(.system(size: 13))
.foregroundStyle(AppDesign.primary)
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(WiredTransferDesign.pageBackground)
}
}

View File

@ -0,0 +1,140 @@
import SwiftUI
///
struct CameraHistoryImportView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var viewModel: CameraHistoryImportViewModel
@State private var showSonyHelp = false
init(viewModel: CameraHistoryImportViewModel) {
_viewModel = StateObject(wrappedValue: viewModel)
}
var body: some View {
NavigationStack {
ZStack {
content
if case .importing = viewModel.state {
importingOverlay
}
}
.background(WiredTransferDesign.pageBackground.ignoresSafeArea())
.navigationTitle("选择照片")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("取消") { dismiss() }
}
ToolbarItem(placement: .primaryAction) {
Button(importButtonTitle) {
viewModel.importSelected()
}
.disabled(selectedCount == 0)
}
if viewModel.shouldShowSonyMTPHelp {
ToolbarItem(placement: .topBarTrailing) {
Button {
showSonyHelp = true
} label: {
Image(systemName: "questionmark.circle")
}
}
}
}
.task { viewModel.loadPhotos() }
.navigationDestination(isPresented: $showSonyHelp) {
SonyMTPHelpView()
}
.onChange(of: viewModel.state) { state in
if case .finished = state {
dismiss()
}
}
}
}
@ViewBuilder
private var content: some View {
switch viewModel.state {
case .idle, .loading:
ProgressView("正在加载相机照片…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
case .loaded, .importing:
photoList(sections: viewModel.sections)
case .finished:
EmptyView()
case .failed(let message):
emptyView(message: message)
}
}
private func photoList(sections: [CameraPhotoSection]) -> some View {
ScrollView {
LazyVStack(spacing: 12, pinnedViews: [.sectionHeaders]) {
ForEach(sections) { section in
Section {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], spacing: 2) {
ForEach(section.photos, id: \.id) { photo in
CameraHistoryImportPhotoCell(
photo: photo,
isImported: viewModel.isPhotoAlreadyInAlbum(id: photo.id),
isSelected: viewModel.isPhotoSelected(id: photo.id),
thumbnailProvider: { await viewModel.thumbnailData(for: photo, maxPixelSize: 160) },
onToggle: { viewModel.togglePhoto(id: photo.id) }
)
}
}
} header: {
CameraHistoryImportSectionHeader(
title: section.title,
isFullySelected: viewModel.isSectionFullySelected(section),
hasImportable: viewModel.hasImportablePhotos(in: section),
onToggleAll: { viewModel.toggleSection(section) }
)
}
}
}
.padding(.horizontal, 2)
}
}
private func emptyView(message: String) -> some View {
VStack(spacing: 16) {
Text(message)
.font(.system(size: 15))
.foregroundStyle(WiredTransferDesign.text666)
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
if viewModel.shouldShowSonyMTPHelp {
Button("如何切换到 MTP 模式") {
showSonyHelp = true
}
.buttonStyle(.borderedProminent)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var importingOverlay: some View {
Group {
if case .importing(let current, let total) = viewModel.state {
VStack(spacing: 12) {
ProgressView()
Text("正在导入 \(current)/\(total)")
.font(.system(size: 15))
}
.padding(24)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}
private var selectedCount: Int {
viewModel.selectedPhotoIDs.count
}
private var importButtonTitle: String {
"导入 (\(selectedCount))"
}
}

View File

@ -0,0 +1,40 @@
import SwiftUI
/// MTP
struct SonyMTPHelpView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text(SonyMTPHelpContent.introduction)
.font(.system(size: 15))
.foregroundStyle(WiredTransferDesign.text333)
ForEach(SonyMTPHelpContent.sections) { section in
VStack(alignment: .leading, spacing: 8) {
Text(section.title)
.font(.system(size: 16, weight: .semibold))
.foregroundStyle(WiredTransferDesign.text333)
Text(section.body)
.font(.system(size: 14))
.foregroundStyle(WiredTransferDesign.text666)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(12)
.background(Color.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
.padding(16)
}
.background(WiredTransferDesign.pageBackground.ignoresSafeArea())
.navigationTitle(SonyMTPHelpContent.navigationTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("关闭") { dismiss() }
}
}
}
}

View File

@ -77,6 +77,7 @@ struct WiredCameraTransferView: View {
WiredTransferBottomBar(
selectUploadMode: viewModel.selectUploadMode,
selectedCount: viewModel.selectedPhotoIDs.count,
isCameraConnected: viewModel.isCameraConnected,
onBatchUploadClick: {
Task {
await viewModel.onBatchUploadButtonClick(
@ -86,7 +87,8 @@ struct WiredCameraTransferView: View {
)
}
},
onSpecifyUploadClick: viewModel.onSpecifyUploadButtonClick
onSpecifyUploadClick: viewModel.onSpecifyUploadButtonClick,
onHistoryImportClick: viewModel.onHistoryImportClick
)
}
.background(WiredTransferDesign.pageBackground.ignoresSafeArea())
@ -155,6 +157,13 @@ struct WiredCameraTransferView: View {
}
}
}
.fullScreenCover(isPresented: $viewModel.showHistoryImport) {
if let importVM = viewModel.makeHistoryImportViewModel(
accountKey: accountContext.accountCachePrefix ?? "guest_"
) {
CameraHistoryImportView(viewModel: importVM)
}
}
.sheet(item: previewBinding) { item in
WiredTransferPhotoPreviewSheet(
imageSources: item.sources,

View File

@ -9,8 +9,10 @@ import SwiftUI
struct WiredTransferBottomBar: View {
let selectUploadMode: Bool
let selectedCount: Int
let isCameraConnected: Bool
let onBatchUploadClick: () -> Void
let onSpecifyUploadClick: () -> Void
let onHistoryImportClick: () -> Void
private var batchButtonTitle: String {
if !selectUploadMode { return "批量上传" }
@ -33,6 +35,20 @@ struct WiredTransferBottomBar: View {
}
.buttonStyle(.plain)
Button(action: onHistoryImportClick) {
Text("历史导入")
.font(.system(size: 15))
.foregroundStyle(isCameraConnected && !selectUploadMode ? AppDesign.primary : WiredTransferDesign.text999)
.frame(maxWidth: .infinity)
.frame(height: WiredTransferDesign.bottomButtonHeight)
.overlay {
RoundedRectangle(cornerRadius: WiredTransferDesign.bottomButtonCornerRadius)
.stroke(AppDesign.primary, lineWidth: 1)
}
}
.buttonStyle(.plain)
.disabled(selectUploadMode || !isCameraConnected)
Button(action: onSpecifyUploadClick) {
Text("指定上传")
.font(.system(size: 15))