完善 OTG 相机有线传图:支持 Sony/Canon 自动识别,页面 detach 保持会话以便再次进入复连。
引入进程级 CameraTetheringSession 与共享 USB Browser;离开传图页仅取消 UI 回调,App 终止时再完整 shutdown 释放 PTP 与 Browser 资源。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@ -69,7 +69,7 @@ SpecifyUploadBottomSheet # 指定上传选项弹层
|
||||
## 业务流程
|
||||
|
||||
1. 创建任务:`POST .../travel-album/create`
|
||||
2. 进入有线传图:Sony 相机 PTP 下载(`Core/CameraTethering`)
|
||||
2. 进入有线传图:Sony/Canon USB 有线传图(`Core/CameraTethering`,自动识别品牌)
|
||||
3. OSS 上传:复用 `OSSUploadService.uploadAlbumFile`
|
||||
4. 素材登记:`POST .../travel-album/upload-material`
|
||||
5. 本地照片路径以账号 + 相册 scoped 目录持久化(如 `CameraDownloads/<accountKey>/<albumID>/originals/xxx.JPG`),该功能未上线,不兼容旧版全局目录记录
|
||||
|
||||
@ -435,6 +435,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
@Published var showCameraDisconnectedAlert = false
|
||||
@Published var showHelpDoc = false
|
||||
@Published var isRefreshingCameraFiles = false
|
||||
@Published var isSearchingCamera = false
|
||||
@Published var deviceStorageInfo = DeviceStorageHelper.readStorageSnapshot()
|
||||
@Published var connectionState: CameraConnectionState = .disconnected
|
||||
@Published var errorMessage: String?
|
||||
@ -450,6 +451,9 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
private var lastProgressPersistDates: [String: Date] = [:]
|
||||
private var wasConnected = false
|
||||
private var disconnectAlertTask: Task<Void, Never>?
|
||||
private var isTearingDownCamera = false
|
||||
/// 用户主动离开页面时为 true,避免误弹「相机已断开」警告。
|
||||
private var isLeavingPage = false
|
||||
|
||||
/// 初始化有线传图 ViewModel。
|
||||
init(
|
||||
@ -458,17 +462,6 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
) {
|
||||
self.context = context
|
||||
self.pipeline = CameraTransferPipeline(cameraService: cameraService)
|
||||
|
||||
pipeline.onConnectionStateChange = { [weak self] state in
|
||||
Task { @MainActor in
|
||||
self?.handleConnectionStateChange(state)
|
||||
}
|
||||
}
|
||||
pipeline.onTasksUpdated = { [weak self] tasks in
|
||||
Task { @MainActor in
|
||||
await self?.applyPipelineTasks(tasks)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 配置上传 Sink 并启动相机连接。
|
||||
@ -478,6 +471,18 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
scenicID: Int,
|
||||
accountContext: AccountContext
|
||||
) async {
|
||||
isLeavingPage = false
|
||||
pipeline.attachToUI(
|
||||
onConnectionStateChange: { [weak self] state in
|
||||
self?.handleConnectionStateChange(state)
|
||||
},
|
||||
onTasksUpdated: { [weak self] tasks in
|
||||
Task { @MainActor in
|
||||
await self?.applyPipelineTasks(tasks)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
let accountCachePrefix = accountContext.accountCachePrefix ?? "guest_"
|
||||
let currentDownloadScope = CameraDownloadStorage.Scope(
|
||||
accountKey: accountCachePrefix,
|
||||
@ -505,15 +510,38 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
pipeline.configure(uploadSink: uploader, uploadEnabled: autoUpload, downloadScope: currentDownloadScope)
|
||||
refreshDeviceStorageInfo()
|
||||
loadPersistedPhotos()
|
||||
await pipeline.connect()
|
||||
connectionState = pipeline.connectionState
|
||||
wasConnected = connectionState.isConnected
|
||||
|
||||
do {
|
||||
await pipeline.connect()
|
||||
connectionState = pipeline.connectionState
|
||||
wasConnected = connectionState.isConnected
|
||||
|
||||
while !Task.isCancelled {
|
||||
try await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
}
|
||||
} catch is CancellationError {
|
||||
// 用户返回或 task 取消。
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
detachFromUI()
|
||||
}
|
||||
|
||||
/// 断开相机连接。
|
||||
/// 页面离开:仅 detach UI 回调,保持相机连接。
|
||||
func stop() async {
|
||||
detachFromUI()
|
||||
}
|
||||
|
||||
/// 页面离开:取消 UI 回调,不关闭相机会话。
|
||||
func detachFromUI() {
|
||||
guard !isTearingDownCamera else { return }
|
||||
isTearingDownCamera = true
|
||||
isLeavingPage = true
|
||||
defer { isTearingDownCamera = false }
|
||||
|
||||
disconnectAlertTask?.cancel()
|
||||
await pipeline.disconnect()
|
||||
showCameraDisconnectedAlert = false
|
||||
pipeline.detachFromUI()
|
||||
}
|
||||
|
||||
/// 刷新设备存储信息。
|
||||
@ -571,12 +599,25 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
/// 刷新相机文件列表。
|
||||
/// 刷新相机文件列表;未连接时重新搜索 USB 相机。
|
||||
func refreshCameraFiles() async {
|
||||
guard connectionState.isConnected, !isRefreshingCameraFiles else { return }
|
||||
isRefreshingCameraFiles = true
|
||||
defer { isRefreshingCameraFiles = false }
|
||||
await pipeline.syncExistingPhotos()
|
||||
if connectionState.isConnected {
|
||||
guard !isRefreshingCameraFiles else { return }
|
||||
isRefreshingCameraFiles = true
|
||||
defer { isRefreshingCameraFiles = false }
|
||||
await pipeline.syncExistingPhotos()
|
||||
return
|
||||
}
|
||||
await restartCameraSearch()
|
||||
}
|
||||
|
||||
/// 重新搜索 USB 相机。
|
||||
func restartCameraSearch() async {
|
||||
guard !connectionState.isConnected, !isSearchingCamera else { return }
|
||||
isSearchingCamera = true
|
||||
defer { isSearchingCamera = false }
|
||||
await pipeline.restartCameraDiscovery()
|
||||
connectionState = pipeline.connectionState
|
||||
}
|
||||
|
||||
/// 批量上传按钮点击(三态:进入勾选 / 上传选中 / 取消选择)。
|
||||
@ -912,7 +953,7 @@ final class WiredCameraTransferViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func handleConnectionStateChange(_ state: CameraConnectionState) {
|
||||
if wasConnected, !state.isConnected {
|
||||
if wasConnected, !state.isConnected, !isLeavingPage {
|
||||
triggerCameraDisconnectedAlert()
|
||||
}
|
||||
wasConnected = state.isConnected
|
||||
|
||||
@ -16,6 +16,7 @@ struct WiredCameraTransferView: View {
|
||||
@Environment(\.ossUploadService) private var ossUploadService
|
||||
@EnvironmentObject private var toastCenter: ToastCenter
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
@StateObject private var viewModel: WiredCameraTransferViewModel
|
||||
@State private var previewSources: [String]?
|
||||
@ -26,7 +27,7 @@ struct WiredCameraTransferView: View {
|
||||
_viewModel = StateObject(
|
||||
wrappedValue: WiredCameraTransferViewModel(
|
||||
context: context,
|
||||
cameraService: CameraServiceFactory.make(brand: .sony)
|
||||
cameraService: CameraServiceFactory.makeAutoDetect()
|
||||
)
|
||||
)
|
||||
}
|
||||
@ -50,6 +51,7 @@ struct WiredCameraTransferView: View {
|
||||
deviceStorageInfo: viewModel.deviceStorageInfo,
|
||||
isCameraConnected: viewModel.isCameraConnected,
|
||||
isRefreshingCameraFiles: viewModel.isRefreshingCameraFiles,
|
||||
isSearchingCamera: viewModel.isSearchingCamera,
|
||||
cameraDeviceName: viewModel.cameraDeviceName,
|
||||
transferModeOption: viewModel.transferModeOption,
|
||||
tabTitles: tabTitles,
|
||||
@ -103,7 +105,11 @@ struct WiredCameraTransferView: View {
|
||||
)
|
||||
}
|
||||
.onDisappear {
|
||||
Task { await viewModel.stop() }
|
||||
viewModel.detachFromUI()
|
||||
}
|
||||
.onChange(of: scenePhase) { phase in
|
||||
guard phase == .active, !viewModel.isCameraConnected else { return }
|
||||
Task { await viewModel.restartCameraSearch() }
|
||||
}
|
||||
.onChange(of: viewModel.errorMessage) { message in
|
||||
guard let message, !message.isEmpty else { return }
|
||||
|
||||
@ -11,6 +11,7 @@ struct WiredTransferControlCard: View {
|
||||
let deviceStorageInfo: DeviceStorageSnapshot
|
||||
let isCameraConnected: Bool
|
||||
let isRefreshingCameraFiles: Bool
|
||||
let isSearchingCamera: Bool
|
||||
let cameraDeviceName: String
|
||||
let transferModeOption: String
|
||||
let tabTitles: [String]
|
||||
@ -130,19 +131,20 @@ struct WiredTransferControlCard: View {
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!isCameraConnected || isRefreshingCameraFiles)
|
||||
.opacity(isCameraConnected && !isRefreshingCameraFiles ? 1 : 0.85)
|
||||
.disabled(isRefreshingCameraFiles || isSearchingCamera)
|
||||
.opacity(isRefreshingCameraFiles || isSearchingCamera ? 0.85 : 1)
|
||||
}
|
||||
|
||||
private var refreshButtonTitle: String {
|
||||
if isSearchingCamera { return "搜索中" }
|
||||
if isRefreshingCameraFiles { return "读取中" }
|
||||
if isCameraConnected { return "刷新" }
|
||||
return "等待连接"
|
||||
return "重新搜索"
|
||||
}
|
||||
|
||||
private var refreshButtonColor: Color {
|
||||
if !isCameraConnected { return WiredTransferDesign.danger }
|
||||
if isRefreshingCameraFiles { return WiredTransferDesign.text999 }
|
||||
if isSearchingCamera || isRefreshingCameraFiles { return WiredTransferDesign.text999 }
|
||||
if !isCameraConnected { return AppDesign.primary }
|
||||
return AppDesign.primary
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user