feat: add travel album OTG import flow
This commit is contained in:
@ -0,0 +1,166 @@
|
||||
//
|
||||
// TravelAlbumOTGTransferModels.swift
|
||||
// suixinkan
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// OTG 传输页展示照片项。
|
||||
struct TravelAlbumOTGPhotoItem: Hashable, Sendable {
|
||||
let id: String
|
||||
let sourceId: String
|
||||
let fileName: String
|
||||
let thumbnailURL: URL?
|
||||
let capturedAt: String
|
||||
let fileSizeText: String
|
||||
let fileSizeBytes: Int64
|
||||
var status: TravelAlbumOTGUploadStatus
|
||||
var progress: Int
|
||||
var errorMessage: String?
|
||||
let localPath: String
|
||||
var remoteUrl: String
|
||||
|
||||
/// 是否未上传完成。
|
||||
var isNotUploaded: Bool {
|
||||
status != .uploaded
|
||||
}
|
||||
|
||||
/// 是否可被选择上传。
|
||||
var canSelectForUpload: Bool {
|
||||
status == .pending || status == .failed
|
||||
}
|
||||
}
|
||||
|
||||
/// OTG 传输页 Tab。
|
||||
enum TravelAlbumOTGTransferTab: Int, CaseIterable, Sendable {
|
||||
case all
|
||||
case uploaded
|
||||
case failed
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .all: return "全部"
|
||||
case .uploaded: return "已上传"
|
||||
case .failed: return "失败"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 相机照片格式匹配工具,对齐 Android `OtgPhotoFormatMatcher`。
|
||||
enum TravelAlbumOTGPhotoFormatMatcher {
|
||||
/// 相机照片类型。
|
||||
enum PhotoKind: Equatable {
|
||||
case jpeg
|
||||
case raw
|
||||
case other
|
||||
}
|
||||
|
||||
private static let jpegExtensions: Set<String> = ["jpg", "jpeg", "heif", "hif"]
|
||||
private static let rawExtensions: Set<String> = [
|
||||
"dng", "cr2", "cr3", "nef", "nrw", "arw", "srf", "sr2",
|
||||
"orf", "rw2", "raw", "raf", "srw", "pef", "ptx", "rwl",
|
||||
]
|
||||
private static let jpegFormatCodes: Set<Int> = [0x3801, 0x3808]
|
||||
private static let rawFormatCodes: Set<Int> = [0x380D, 0xB101, 0xB103, 0xB108]
|
||||
|
||||
/// 判断文件类型。
|
||||
static func classify(name: String, format: Int = 0, isVideo: Bool = false) -> PhotoKind {
|
||||
if isVideo { return .other }
|
||||
let ext = URL(fileURLWithPath: name).pathExtension.lowercased()
|
||||
if rawExtensions.contains(ext) || rawFormatCodes.contains(format) {
|
||||
return .raw
|
||||
}
|
||||
if jpegExtensions.contains(ext) || jpegFormatCodes.contains(format) {
|
||||
return .jpeg
|
||||
}
|
||||
return .other
|
||||
}
|
||||
|
||||
/// 判断相机对象是否匹配当前上传格式。
|
||||
static func matchesUploadKind(_ object: CameraObject, option: TravelAlbumOTGPhotoFormatOption) -> Bool {
|
||||
switch option {
|
||||
case .jpg:
|
||||
return classify(name: object.filename) == .jpeg
|
||||
case .raw:
|
||||
return classify(name: object.filename) == .raw
|
||||
case .jpegRaw:
|
||||
let kind = classify(name: object.filename)
|
||||
return kind == .jpeg || kind == .raw
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据格式偏好展开实际上传目标。
|
||||
static func resolveUploadTargets(
|
||||
requested: [CameraObject],
|
||||
allObjects: [CameraObject],
|
||||
option: TravelAlbumOTGPhotoFormatOption
|
||||
) -> [CameraObject] {
|
||||
guard !requested.isEmpty else { return [] }
|
||||
var ordered: [CameraObject] = []
|
||||
var seen = Set<String>()
|
||||
for object in requested {
|
||||
let peers = peers(of: object, allObjects: allObjects)
|
||||
let jpegs = peers.filter { classify(name: $0.filename) == .jpeg }
|
||||
let raws = peers.filter { classify(name: $0.filename) == .raw }
|
||||
let candidates: [CameraObject]
|
||||
switch option {
|
||||
case .jpg:
|
||||
candidates = Array(jpegs.prefix(1))
|
||||
case .raw:
|
||||
candidates = Array(raws.prefix(1))
|
||||
case .jpegRaw:
|
||||
candidates = jpegs + raws
|
||||
}
|
||||
candidates.forEach { candidate in
|
||||
if seen.insert(candidate.id).inserted {
|
||||
ordered.append(candidate)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ordered
|
||||
}
|
||||
|
||||
private static func peers(of object: CameraObject, allObjects: [CameraObject]) -> [CameraObject] {
|
||||
let stem = captureStem(object.filename)
|
||||
guard !stem.isEmpty else { return [object] }
|
||||
let matches = allObjects.filter { captureStem($0.filename) == stem }
|
||||
return matches.isEmpty ? [object] : matches
|
||||
}
|
||||
|
||||
private static func captureStem(_ name: String) -> String {
|
||||
(name as NSString).deletingPathExtension.uppercased()
|
||||
}
|
||||
}
|
||||
|
||||
extension TravelAlbumOTGPhotoRecord {
|
||||
/// 转为页面展示项。
|
||||
func toPhotoItem() -> TravelAlbumOTGPhotoItem {
|
||||
let previewPath = [thumbnailPath, localPath, remoteUrl].first { path in
|
||||
guard !path.isEmpty else { return false }
|
||||
if path.hasPrefix("http") { return true }
|
||||
return FileManager.default.fileExists(atPath: path)
|
||||
} ?? ""
|
||||
let previewURL: URL?
|
||||
if previewPath.hasPrefix("http") {
|
||||
previewURL = URL(string: previewPath)
|
||||
} else if !previewPath.isEmpty {
|
||||
previewURL = URL(fileURLWithPath: previewPath)
|
||||
} else {
|
||||
previewURL = nil
|
||||
}
|
||||
return TravelAlbumOTGPhotoItem(
|
||||
id: id,
|
||||
sourceId: sourceId,
|
||||
fileName: fileName,
|
||||
thumbnailURL: previewURL,
|
||||
capturedAt: capturedAt,
|
||||
fileSizeText: TravelAlbumOTGPhotoStore.fileSizeText(fileSizeBytes),
|
||||
fileSizeBytes: fileSizeBytes,
|
||||
status: status,
|
||||
progress: progress,
|
||||
errorMessage: errorMessage,
|
||||
localPath: localPath,
|
||||
remoteUrl: remoteUrl
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user