完善 OTG 相机有线传图:支持 Sony/Canon 自动识别,页面 detach 保持会话以便再次进入复连。

引入进程级 CameraTetheringSession 与共享 USB Browser;离开传图页仅取消 UI 回调,App 终止时再完整 shutdown 释放 PTP 与 Browser 资源。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-01 15:45:35 +08:00
parent 5f7ef24683
commit 90d135b5ac
34 changed files with 2870 additions and 310 deletions

View File

@ -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`),该功能未上线,不兼容旧版全局目录记录

View File

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

View File

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

View File

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