feat: 移除内置播放器,统一外部播放体验
- 移除内置 AVPlayer 播放器,视频文件统一使用外部播放器 - 播放器选择器优化为 Grid 布局,显示真实应用图标 - 右键菜单合并为"播放"菜单,直接列出所有播放器 - 详情页剧集列表增加右键菜单 - 媒体库所有上下文菜单统一播放入口 - 修复启动时自动清理已删除文件的秒传记录 - 添加 prepareAndPerformFastTransfer 静态方法
This commit is contained in:
@@ -14,16 +14,18 @@
|
||||
8C0E0B522C40000100A10001 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B422C40000100A10001 /* Models.swift */; };
|
||||
8C0E0B532C40000100A10001 /* GuangyaAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B432C40000100A10001 /* GuangyaAPI.swift */; };
|
||||
8C0E0B542C40000100A10001 /* AppModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B442C40000100A10001 /* AppModel.swift */; };
|
||||
8C0E0B552C40000100A10001 /* 光鸭.svg in Resources */ = {isa = PBXBuildFile; fileRef = 8C0E0B452C40000100A10001 /* 光鸭.svg */; };
|
||||
8C0E0B562C40000100A10001 /* FileMetadataCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B462C40000100A10001 /* FileMetadataCache.swift */; };
|
||||
8C0E0B572C40000100A10001 /* AppTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B472C40000100A10001 /* AppTheme.swift */; };
|
||||
8C0E0B582C40000100A10001 /* WorkspaceShell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B482C40000100A10001 /* WorkspaceShell.swift */; };
|
||||
8C0E0B592C40000100A10001 /* BatchRenameTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B612C40000100A10001 /* BatchRenameTool.swift */; };
|
||||
8C0E0B632C40000100A10001 /* MediaLibraryPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B622C40000100A10001 /* MediaLibraryPage.swift */; };
|
||||
8C0E0B652C40000100A10001 /* MediaLibraryStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0E0B642C40000100A10001 /* MediaLibraryStore.swift */; };
|
||||
8C0E0B552C40000100A10001 /* 光鸭.svg in Resources */ = {isa = PBXBuildFile; fileRef = 8C0E0B452C40000100A10001 /* 光鸭.svg */; };
|
||||
E25B80319B73188D1EA1EC71 /* DualPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FE17776E66D537CC5965522 /* DualPanel.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
0FE17776E66D537CC5965522 /* DualPanel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DualPanel.swift; sourceTree = "<group>"; };
|
||||
8C0E0B332C40000100A10001 /* guangya_mac.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = guangya_mac.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
8C0E0B362C40000100A10001 /* guangya_macApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = guangya_macApp.swift; sourceTree = "<group>"; };
|
||||
8C0E0B372C40000100A10001 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
@@ -85,6 +87,7 @@
|
||||
8C0E0B452C40000100A10001 /* 光鸭.svg */,
|
||||
8C0E0B382C40000100A10001 /* Assets.xcassets */,
|
||||
8C0E0B392C40000100A10001 /* Preview Content */,
|
||||
0FE17776E66D537CC5965522 /* DualPanel.swift */,
|
||||
);
|
||||
path = guangya_mac;
|
||||
sourceTree = "<group>";
|
||||
@@ -125,7 +128,7 @@
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 2630;
|
||||
LastUpgradeCheck = 2630;
|
||||
LastUpgradeCheck = 2640;
|
||||
TargetAttributes = {
|
||||
8C0E0B322C40000100A10001 = {
|
||||
CreatedOnToolsVersion = 26.3;
|
||||
@@ -157,7 +160,7 @@
|
||||
files = (
|
||||
8C0E0B402C40000100A10001 /* Assets.xcassets in Resources */,
|
||||
8C0E0B412C40000100A10001 /* Preview Assets.xcassets in Resources */,
|
||||
8C0E0B552C40000100A10001 /* 光鸭.svg in Resources */,
|
||||
8C0E0B552C40000100A10001 /* 光鸭.svg in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -170,15 +173,16 @@
|
||||
files = (
|
||||
8C0E0B3E2C40000100A10001 /* guangya_macApp.swift in Sources */,
|
||||
8C0E0B3F2C40000100A10001 /* ContentView.swift in Sources */,
|
||||
8C0E0B522C40000100A10001 /* Models.swift in Sources */,
|
||||
8C0E0B532C40000100A10001 /* GuangyaAPI.swift in Sources */,
|
||||
8C0E0B542C40000100A10001 /* AppModel.swift in Sources */,
|
||||
8C0E0B522C40000100A10001 /* Models.swift in Sources */,
|
||||
8C0E0B532C40000100A10001 /* GuangyaAPI.swift in Sources */,
|
||||
8C0E0B542C40000100A10001 /* AppModel.swift in Sources */,
|
||||
8C0E0B562C40000100A10001 /* FileMetadataCache.swift in Sources */,
|
||||
8C0E0B572C40000100A10001 /* AppTheme.swift in Sources */,
|
||||
8C0E0B582C40000100A10001 /* WorkspaceShell.swift in Sources */,
|
||||
8C0E0B592C40000100A10001 /* BatchRenameTool.swift in Sources */,
|
||||
8C0E0B632C40000100A10001 /* MediaLibraryPage.swift in Sources */,
|
||||
8C0E0B652C40000100A10001 /* MediaLibraryStore.swift in Sources */,
|
||||
E25B80319B73188D1EA1EC71 /* DualPanel.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -194,6 +198,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"guangya_mac/Preview Content\"";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -205,7 +210,7 @@
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.guangya-mac;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.example.guangya-mac";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = macosx;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -222,6 +227,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"guangya_mac/Preview Content\"";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -233,7 +239,7 @@
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.example.guangya-mac;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.example.guangya-mac";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = macosx;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@@ -273,6 +279,7 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
@@ -296,6 +303,7 @@
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = macosx;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
@@ -333,6 +341,7 @@
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
@@ -349,6 +358,7 @@
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = macosx;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
|
||||
+2306
-132
File diff suppressed because it is too large
Load Diff
+1484
-180
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,519 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
// MARK: - Dual Panel View
|
||||
|
||||
struct DualPanelView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@StateObject private var dualState: DualPanelState
|
||||
|
||||
init(model: AppModel) {
|
||||
self.model = model
|
||||
_dualState = StateObject(wrappedValue: DualPanelState(api: model.api))
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HSplitView {
|
||||
PanelFilesBrowser(model: model, panel: dualState.leftPanel, counterpart: dualState.rightPanel, label: "左")
|
||||
.frame(minWidth: 340)
|
||||
|
||||
PanelFilesBrowser(model: model, panel: dualState.rightPanel, counterpart: dualState.leftPanel, label: "右")
|
||||
.frame(minWidth: 340)
|
||||
}
|
||||
.task {
|
||||
await dualState.activate()
|
||||
// Sync left panel with current model state
|
||||
if dualState.leftPanel.files.isEmpty {
|
||||
await dualState.leftPanel.loadFiles(folderID: model.folderPath.last?.id)
|
||||
dualState.leftPanel.folderPath = model.folderPath
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dual Panel State
|
||||
|
||||
@MainActor
|
||||
final class DualPanelState: ObservableObject {
|
||||
let leftPanel: PanelState
|
||||
let rightPanel: PanelState
|
||||
let api: GuangyaAPI
|
||||
|
||||
init(api: GuangyaAPI) {
|
||||
self.api = api
|
||||
self.leftPanel = PanelState(api: api, id: 0)
|
||||
self.rightPanel = PanelState(api: api, id: 1)
|
||||
}
|
||||
|
||||
func activate() async {
|
||||
// Load initial data if not already loaded
|
||||
if leftPanel.files.isEmpty {
|
||||
await leftPanel.loadFiles()
|
||||
}
|
||||
if rightPanel.files.isEmpty {
|
||||
await rightPanel.loadFiles()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Panel State (independent navigation for each panel)
|
||||
|
||||
@MainActor
|
||||
final class PanelState: ObservableObject {
|
||||
let api: GuangyaAPI
|
||||
let id: Int // 0 = left, 1 = right
|
||||
|
||||
@Published var files: [CloudFile] = []
|
||||
@Published var folderPath: [FolderPath] = []
|
||||
@Published var isLoadingFiles = false
|
||||
@Published var isMovingFiles = false
|
||||
@Published var selectedFileIDs: Set<String> = []
|
||||
@Published var section: WorkspaceSection = .files
|
||||
|
||||
private var currentPage: Int = 0
|
||||
private let pageSize: Int = 100
|
||||
|
||||
var currentFolderID: String? {
|
||||
if let last = folderPath.last { return last.id }
|
||||
return nil
|
||||
}
|
||||
|
||||
init(api: GuangyaAPI, id: Int) {
|
||||
self.api = api
|
||||
self.id = id
|
||||
}
|
||||
|
||||
func loadFiles(folderID: String? = nil) async {
|
||||
isLoadingFiles = true
|
||||
defer { isLoadingFiles = false }
|
||||
|
||||
do {
|
||||
let response = try await api.fsFiles(parentID: folderID, page: currentPage, pageSize: pageSize)
|
||||
self.files = extractCloudFiles(from: response)
|
||||
} catch {
|
||||
self.files = []
|
||||
print("⚠️ [Panel \(id)] loadFiles failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func navigateToRoot() async {
|
||||
folderPath = []
|
||||
await loadFiles(folderID: nil)
|
||||
}
|
||||
|
||||
func navigateToFolder(at index: Int) async {
|
||||
guard index < folderPath.count else { return }
|
||||
let folderID = folderPath[index].id
|
||||
let newPath = Array(folderPath.prefix(index + 1))
|
||||
folderPath = newPath
|
||||
await loadFiles(folderID: folderID)
|
||||
}
|
||||
|
||||
func openFolder(_ folder: CloudFile) async {
|
||||
folderPath.append(FolderPath(id: folder.id, name: folder.name))
|
||||
await loadFiles(folderID: folder.id)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Panel Browser View (one side of the dual panel)
|
||||
|
||||
struct PanelFilesBrowser: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@ObservedObject var panel: PanelState
|
||||
@ObservedObject var counterpart: PanelState
|
||||
let label: String
|
||||
|
||||
@State private var showFileDetail: CloudFile?
|
||||
@State private var isDropTargeted = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Breadcrumb header
|
||||
breadcrumbHeader
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
|
||||
Divider()
|
||||
|
||||
// File list
|
||||
if panel.isLoadingFiles {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
Spacer()
|
||||
} else if panel.files.isEmpty {
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "folder")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("空文件夹")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
} else {
|
||||
fileListView
|
||||
}
|
||||
|
||||
// Status bar
|
||||
statusBar
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
.background(Color(nsColor: .controlBackgroundColor))
|
||||
}
|
||||
.background(Color(nsColor: .windowBackgroundColor))
|
||||
.overlay {
|
||||
if isDropTargeted {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.stroke(Color.orange, style: StrokeStyle(lineWidth: 2, dash: [6, 4]))
|
||||
.fill(Color.orange.opacity(0.08))
|
||||
.padding(4)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if panel.isMovingFiles {
|
||||
ZStack {
|
||||
Color.black.opacity(0.12)
|
||||
Label("正在移动文件…", systemImage: "arrow.left.arrow.right")
|
||||
.font(.callout.weight(.semibold))
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
.background(.regularMaterial, in: Capsule())
|
||||
}
|
||||
.allowsHitTesting(true)
|
||||
}
|
||||
}
|
||||
.onDrop(of: [.utf8PlainText], isTargeted: $isDropTargeted, perform: receivePanelDrop)
|
||||
.onChange(of: showFileDetail) { _, file in
|
||||
if let file {
|
||||
PlayerWindowCoordinator.shared.open(model: model, file: file)
|
||||
showFileDetail = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var breadcrumbHeader: some View {
|
||||
HStack(spacing: 6) {
|
||||
Button {
|
||||
Task { await panel.navigateToRoot() }
|
||||
} label: {
|
||||
Image(systemName: "house.fill")
|
||||
.font(.system(size: 11, weight: .bold))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.orange)
|
||||
.help("返回根目录")
|
||||
|
||||
ForEach(Array(panel.folderPath.enumerated()), id: \.element.id) { index, folder in
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(.tertiary)
|
||||
Button(folder.name) {
|
||||
Task { await panel.navigateToFolder(at: index) }
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.caption.weight(index == panel.folderPath.count - 1 ? .bold : .medium))
|
||||
.foregroundStyle(index == panel.folderPath.count - 1 ? .primary : .secondary)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.background(
|
||||
index == panel.folderPath.count - 1
|
||||
? Color.orange.opacity(0.12)
|
||||
: Color.clear,
|
||||
in: Capsule()
|
||||
)
|
||||
}
|
||||
|
||||
if panel.folderPath.isEmpty {
|
||||
Text("全部文件")
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
Task { await panel.loadFiles(folderID: panel.currentFolderID) }
|
||||
} label: {
|
||||
if panel.isLoadingFiles {
|
||||
ProgressView().controlSize(.mini)
|
||||
} else {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(panel.isLoadingFiles)
|
||||
.help("刷新此面板")
|
||||
}
|
||||
}
|
||||
|
||||
private var fileListView: some View {
|
||||
List(selection: $panel.selectedFileIDs) {
|
||||
ForEach(panel.files) { file in
|
||||
PanelBrowserRow(file: file, panel: counterpart)
|
||||
.tag(file.id)
|
||||
.onTapGesture {
|
||||
panel.selectedFileIDs = [file.id]
|
||||
}
|
||||
.onTapGesture(count: 2) {
|
||||
if file.isDirectory {
|
||||
Task { await panel.openFolder(file) }
|
||||
} else {
|
||||
showFileDetail = file
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
Button("在 Finder 中打开") {
|
||||
Task { await openInFinder(file) }
|
||||
}
|
||||
if file.isDirectory {
|
||||
Button("打开文件夹") {
|
||||
Task { await panel.openFolder(file) }
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
Button("移动到 \(counterpart.id == 0 ? "左" : "右")面板") {
|
||||
Task { await moveFileToCounterpart(file) }
|
||||
}
|
||||
}
|
||||
.onDrag { NSItemProvider(object: file.id as NSString) }
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
.refreshable {
|
||||
await panel.loadFiles(folderID: panel.currentFolderID)
|
||||
}
|
||||
}
|
||||
|
||||
private var statusBar: some View {
|
||||
HStack(spacing: 12) {
|
||||
Label("文件 \(panel.files.filter { !$0.isDirectory }.count)", systemImage: "doc.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
Label("文件夹 \(panel.files.filter(\.isDirectory).count)", systemImage: "folder.fill")
|
||||
.foregroundStyle(.secondary)
|
||||
if panel.isLoadingFiles {
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.mini)
|
||||
Text("加载中…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.font(.caption2)
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
private func openInFinder(_ file: CloudFile) async {
|
||||
// Placeholder - would need actual implementation
|
||||
print("Open in Finder: \(file.name)")
|
||||
}
|
||||
|
||||
private func moveFileToCounterpart(_ file: CloudFile) async {
|
||||
await moveFiles(ids: [file.id], to: counterpart.currentFolderID)
|
||||
}
|
||||
|
||||
private func moveFiles(ids: [String], to targetFolderID: String?) async {
|
||||
panel.isMovingFiles = true
|
||||
counterpart.isMovingFiles = true
|
||||
defer {
|
||||
panel.isMovingFiles = false
|
||||
counterpart.isMovingFiles = false
|
||||
}
|
||||
_ = try? await panel.api.fsMove(fileIDs: ids, parentID: targetFolderID)
|
||||
// Refresh source and destination together after the move request ends.
|
||||
async let currentRefresh: Void = panel.loadFiles(folderID: panel.currentFolderID)
|
||||
async let counterpartRefresh: Void = counterpart.loadFiles(folderID: counterpart.currentFolderID)
|
||||
_ = await (currentRefresh, counterpartRefresh)
|
||||
}
|
||||
|
||||
private func receivePanelDrop(_ providers: [NSItemProvider]) -> Bool {
|
||||
let validProviders = providers.filter { $0.canLoadObject(ofClass: NSString.self) }
|
||||
guard !validProviders.isEmpty else { return false }
|
||||
let targetFileIDs = Set(panel.files.map(\.id))
|
||||
for provider in validProviders {
|
||||
_ = provider.loadObject(ofClass: NSString.self) { value, _ in
|
||||
guard let value = value as? NSString else { return }
|
||||
let id = value as String
|
||||
// Dropping an item back into its own currently opened folder is
|
||||
// a no-op; only an item from the other panel is moved.
|
||||
guard !targetFileIDs.contains(id) else { return }
|
||||
Task { @MainActor in await moveFiles(ids: [id], to: panel.currentFolderID) }
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Panel Browser Row
|
||||
|
||||
struct PanelBrowserRow: View {
|
||||
let file: CloudFile
|
||||
let panel: PanelState
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
// Icon
|
||||
Image(systemName: fileIcon)
|
||||
.font(.title3)
|
||||
.foregroundStyle(file.isDirectory ? .orange : .blue)
|
||||
.frame(width: 24)
|
||||
|
||||
// File info
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(file.name)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
if !file.modifiedAt.isEmpty {
|
||||
Text(file.modifiedAt)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Size
|
||||
if let size = file.size {
|
||||
Text(ByteCountFormatter.string(fromByteCount: size, countStyle: .file))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
|
||||
private var fileIcon: String {
|
||||
if file.isDirectory { return "folder.fill" }
|
||||
return "doc.fill"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dual Panel Action Bar (replicates FilesBrowser's action bar)
|
||||
|
||||
struct DualPanelActionBar: View {
|
||||
let model: AppModel
|
||||
@Binding var isDualPanel: Bool
|
||||
@Binding var isImporting: Bool
|
||||
@Binding var isCreatingFolder: Bool
|
||||
let onOpenTool: (WorkspaceTool) -> Void
|
||||
@State private var isRefreshing = false
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
// Dual panel toggle
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { isDualPanel.toggle() }
|
||||
} label: {
|
||||
Image(systemName: isDualPanel ? "rectangle.split.2x1" : "rectangle.on.rectangle")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.help(isDualPanel ? "切换到单面板" : "左右双面板浏览")
|
||||
|
||||
Spacer()
|
||||
|
||||
// Upload
|
||||
Button { isImporting = true } label: {
|
||||
Label("上传", systemImage: "arrow.up")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.orange)
|
||||
|
||||
// New folder
|
||||
Button { isCreatingFolder = true } label: {
|
||||
Image(systemName: "folder.badge.plus")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.help("新建文件夹")
|
||||
|
||||
// Refresh
|
||||
Button {
|
||||
Task { await refresh() }
|
||||
} label: {
|
||||
Image(systemName: isRefreshing ? "arrow.triangle.2.circlepath" : "arrow.clockwise")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(isRefreshing)
|
||||
.help("刷新")
|
||||
|
||||
// More menu
|
||||
Menu {
|
||||
ForEach(WorkspaceTool.allCases) { tool in
|
||||
Button(tool.displayName) { onOpenTool(tool) }
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
.frame(width: 28, height: 28)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(.ultraThinMaterial)
|
||||
Divider()
|
||||
}
|
||||
|
||||
private func refresh() async {
|
||||
isRefreshing = true
|
||||
defer { isRefreshing = false }
|
||||
await model.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helper Extensions
|
||||
|
||||
extension WorkspaceTool {
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .media: return "影视库"
|
||||
case .scan: return "文件扫描与清理"
|
||||
case .rename: return "批量重命名"
|
||||
case .fastTransfer: return "秒传工具"
|
||||
case .tmdb: return "TMDB 整理"
|
||||
case .settings: return "工作区设置"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cross-panel Drop Coordinator
|
||||
|
||||
@MainActor
|
||||
final class DualPanelDropCoordinator: ObservableObject {
|
||||
let leftPanel: PanelState
|
||||
let rightPanel: PanelState
|
||||
private let api: GuangyaAPI
|
||||
|
||||
init(leftPanel: PanelState, rightPanel: PanelState, api: GuangyaAPI) {
|
||||
self.leftPanel = leftPanel
|
||||
self.rightPanel = rightPanel
|
||||
self.api = api
|
||||
}
|
||||
|
||||
func handleDrop(from source: PanelState, files: [CloudFile], to targetFolderID: String?) async {
|
||||
let destination = source === leftPanel ? rightPanel : leftPanel
|
||||
source.isMovingFiles = true
|
||||
destination.isMovingFiles = true
|
||||
defer {
|
||||
source.isMovingFiles = false
|
||||
destination.isMovingFiles = false
|
||||
}
|
||||
do {
|
||||
_ = try await api.fsMove(fileIDs: files.map(\.id), parentID: targetFolderID)
|
||||
} catch {
|
||||
print("⚠️ Move failed: \(error)")
|
||||
}
|
||||
async let sourceRefresh: Void = source.loadFiles(folderID: source.currentFolderID)
|
||||
async let destinationRefresh: Void = destination.loadFiles(folderID: destination.currentFolderID)
|
||||
_ = await (sourceRefresh, destinationRefresh)
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,10 @@ final class GuangyaAPI: @unchecked Sendable {
|
||||
configuration.timeoutIntervalForResource = 120
|
||||
configuration.waitsForConnectivity = true
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
configuration.httpMaximumConnectionsPerHost = 8
|
||||
configuration.httpMaximumConnectionsPerHost = 20
|
||||
// Keep the app's long-lived API session eligible for HTTP/2 connection
|
||||
// reuse while seconds-transfer sends its request sequence.
|
||||
configuration.httpShouldUsePipelining = true
|
||||
configuration.httpAdditionalHeaders = [
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
"Content-Type": "application/json",
|
||||
@@ -221,7 +224,7 @@ final class GuangyaAPI: @unchecked Sendable {
|
||||
func fsCreateDir(name: String, parentID: String? = nil, failIfNameExist: Bool = false) async throws -> JSONValue {
|
||||
var body = ["dirName": JSONValue.string(name), "parentId": apiIDOrEmpty(parentID)]
|
||||
if failIfNameExist { body["failIfNameExist"] = .bool(true) }
|
||||
return try await apiRequest("/nd.bizuserres.s/v1/file/create_dir", body: .object(body))
|
||||
return try await apiRequest("/nd.bizuserres.s/v1/file/create_dir", body: .object(body), allowedCodes: failIfNameExist ? [159] : [])
|
||||
}
|
||||
func fsCopy(fileIDs: [String], parentID: String? = nil) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/copy_file", body: object(("fileIds", apiIDs(fileIDs)), ("parentId", apiIDOrEmpty(parentID)))) }
|
||||
func fsMove(fileIDs: [String], parentID: String? = nil) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/move_file", body: object(("fileIds", apiIDs(fileIDs)), ("parentId", apiIDOrEmpty(parentID)))) }
|
||||
@@ -259,46 +262,45 @@ final class GuangyaAPI: @unchecked Sendable {
|
||||
|
||||
func uploadToken(name: String, fileSize: Int64, parentID: String? = nil, md5: String? = nil) async throws -> JSONValue {
|
||||
var res: [String: JSONValue] = ["fileSize": .number(Double(fileSize))]
|
||||
if let md5 { res["md5"] = .string(md5) }
|
||||
return try await apiRequest("/nd.bizuserres.s/v1/get_res_center_token", body: .object(["capacity": .number(2), "name": .string(name), "res": .object(res), "parentId": apiIDOrEmpty(parentID)]))
|
||||
if let md5 { res["md5"] = .string(Self.base64MD5(md5)) }
|
||||
return try await apiRequest("/nd.bizuserres.s/v1/get_res_center_token", body: .object(["capacity": .number(2), "name": .string(name), "res": .object(res), "parentId": apiIDOrEmpty(parentID)]), allowedCodes: [156])
|
||||
}
|
||||
func checkCanFlashUpload(taskID: String, gcid: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/check_can_flash_upload", body: object(("taskId", taskID), ("gcid", gcid))) }
|
||||
func uploadInfo(taskID: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/get_info_by_task_id", body: object(("taskId", taskID))) }
|
||||
|
||||
func tmdbSearch(query: String, apiKey: String, mediaKind: TMDBMediaKind = .automatic, proxyHost: String = "", proxyPort: String = "") async throws -> JSONValue {
|
||||
/// The web client's seconds-transfer workflow probes a hexadecimal MD5 with
|
||||
/// capacity 1. A 156 business code means the file was linked immediately.
|
||||
func flashTransferToken(name: String, fileSize: Int64, parentID: String? = nil, md5: String) async throws -> JSONValue {
|
||||
let normalizedMD5 = md5.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
let res: [String: JSONValue] = ["md5": .string(normalizedMD5), "fileSize": .number(Double(fileSize))]
|
||||
return try await apiRequest("/nd.bizuserres.s/v1/get_res_center_token", body: .object(["capacity": .number(1), "name": .string(name), "res": .object(res), "parentId": apiIDOrEmpty(parentID)]), allowedCodes: [156])
|
||||
}
|
||||
|
||||
/// Guangya GCID exports use the same capacity-1 instant-transfer endpoint
|
||||
/// as MD5 exports, with gcid placed directly in res.
|
||||
func flashTransferGCIDToken(name: String, fileSize: Int64, parentID: String? = nil, gcid: String) async throws -> JSONValue {
|
||||
let normalizedGCID = gcid.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
let res: [String: JSONValue] = ["gcid": .string(normalizedGCID), "fileSize": .number(Double(fileSize))]
|
||||
return try await apiRequest("/nd.bizuserres.s/v1/get_res_center_token", body: .object(["capacity": .number(1), "name": .string(name), "res": .object(res), "parentId": apiIDOrEmpty(parentID)]), allowedCodes: [156])
|
||||
}
|
||||
|
||||
func deleteUploadTask(taskIDs: [String]) async throws -> JSONValue {
|
||||
try await apiRequest("/nd.bizuserres.s/v1/file/delete_upload_task", body: object(("taskIds", taskIDs)))
|
||||
}
|
||||
|
||||
func checkCanFlashUpload(taskID: String, gcid: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/check_can_flash_upload", body: object(("taskId", taskID), ("gcid", gcid))) }
|
||||
func uploadInfo(taskID: String) async throws -> JSONValue { try await apiRequest("/nd.bizuserres.s/v1/file/get_info_by_task_id", body: object(("taskId", taskID)), allowedCodes: [145, 146, 155, 163]) }
|
||||
|
||||
func tmdbSearch(query: String, apiKey: String, mediaKind: TMDBMediaKind = .automatic, proxyHost: String = "", proxyPort: String = "", year: Int? = nil) async throws -> JSONValue {
|
||||
let endpoint = mediaKind == .movie ? "movie" : (mediaKind == .tv ? "tv" : "multi")
|
||||
var components = URLComponents(string: "https://api.themoviedb.org/3/search/\(endpoint)")!
|
||||
components.queryItems = [URLQueryItem(name: "api_key", value: apiKey), URLQueryItem(name: "query", value: query), URLQueryItem(name: "language", value: "zh-CN"), URLQueryItem(name: "region", value: "CN"), URLQueryItem(name: "include_adult", value: "false")]
|
||||
var request = URLRequest(url: components.url!)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
// Cloud listing/detail calls can be slow on congested networks. Let the
|
||||
// system wait for a route and retry transient failures below instead of
|
||||
// surfacing the default 15-second timeout to the user.
|
||||
configuration.timeoutIntervalForRequest = 45
|
||||
configuration.timeoutIntervalForResource = 120
|
||||
configuration.waitsForConnectivity = true
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
configuration.httpMaximumConnectionsPerHost = 8
|
||||
let normalizedHost = proxyHost
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.replacingOccurrences(of: "^https?://", with: "", options: .regularExpression)
|
||||
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
if !normalizedHost.isEmpty, let port = Int(proxyPort), (1...65535).contains(port) {
|
||||
configuration.connectionProxyDictionary = [
|
||||
kCFNetworkProxiesHTTPEnable as String: true,
|
||||
kCFNetworkProxiesHTTPProxy as String: normalizedHost,
|
||||
kCFNetworkProxiesHTTPPort as String: port,
|
||||
kCFNetworkProxiesHTTPSEnable as String: true,
|
||||
kCFNetworkProxiesHTTPSProxy as String: normalizedHost,
|
||||
kCFNetworkProxiesHTTPSPort as String: port
|
||||
]
|
||||
if let year {
|
||||
switch mediaKind {
|
||||
case .movie: components.queryItems?.append(URLQueryItem(name: "primary_release_year", value: String(year)))
|
||||
case .tv: components.queryItems?.append(URLQueryItem(name: "first_air_date_year", value: String(year)))
|
||||
case .automatic: break
|
||||
}
|
||||
}
|
||||
let tmdbSession = URLSession(configuration: configuration)
|
||||
let (data, response) = try await tmdbSession.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw GuangyaAPIError.http(status: (response as? HTTPURLResponse)?.statusCode ?? 0, message: "TMDB 请求失败") }
|
||||
return try JSONDecoder().decode(JSONValue.self, from: data)
|
||||
return try await tmdbJSONRequest(components.url!, proxyHost: proxyHost, proxyPort: proxyPort)
|
||||
}
|
||||
|
||||
func tmdbDetails(id: Int, mediaKind: TMDBMediaKind, apiKey: String, proxyHost: String = "", proxyPort: String = "") async throws -> JSONValue {
|
||||
@@ -326,12 +328,31 @@ final class GuangyaAPI: @unchecked Sendable {
|
||||
|
||||
private func tmdbDataRequest(_ url: URL, proxyHost: String, proxyPort: String) async throws -> Data {
|
||||
var request = URLRequest(url: url); request.httpMethod = "GET"; request.timeoutInterval = 45; request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
let config = URLSessionConfiguration.ephemeral; config.timeoutIntervalForRequest = 45; config.timeoutIntervalForResource = 120
|
||||
let host = proxyHost.trimmingCharacters(in: .whitespacesAndNewlines).replacingOccurrences(of: "^https?://", with: "", options: .regularExpression).trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||
if !host.isEmpty, let port = Int(proxyPort), (1...65535).contains(port) { config.connectionProxyDictionary = [kCFNetworkProxiesHTTPEnable as String: true, kCFNetworkProxiesHTTPProxy as String: host, kCFNetworkProxiesHTTPPort as String: port, kCFNetworkProxiesHTTPSEnable as String: true, kCFNetworkProxiesHTTPSProxy as String: host, kCFNetworkProxiesHTTPSPort as String: port] }
|
||||
let (data, response) = try await URLSession(configuration: config).data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw GuangyaAPIError.http(status: (response as? HTTPURLResponse)?.statusCode ?? 0, message: "TMDB 请求失败") }
|
||||
return data
|
||||
let port = Int(proxyPort).flatMap { (1...65535).contains($0) ? $0 : nil }
|
||||
func fetch(useProxy: Bool) async throws -> Data {
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = 45; config.timeoutIntervalForResource = 120
|
||||
config.waitsForConnectivity = true; config.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
if useProxy, let port {
|
||||
config.connectionProxyDictionary = [kCFNetworkProxiesHTTPEnable as String: true, kCFNetworkProxiesHTTPProxy as String: host, kCFNetworkProxiesHTTPPort as String: port, kCFNetworkProxiesHTTPSEnable as String: true, kCFNetworkProxiesHTTPSProxy as String: host, kCFNetworkProxiesHTTPSPort as String: port]
|
||||
}
|
||||
let (data, response) = try await URLSession(configuration: config).data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { throw GuangyaAPIError.http(status: (response as? HTTPURLResponse)?.statusCode ?? 0, message: "TMDB 请求失败") }
|
||||
return data
|
||||
}
|
||||
guard !host.isEmpty, port != nil else { return try await fetch(useProxy: false) }
|
||||
do { return try await fetch(useProxy: true) }
|
||||
catch {
|
||||
// Some local HTTP proxies intermittently fail the CONNECT/TLS tunnel.
|
||||
// TMDB is safe to retry directly when that specific transport fails.
|
||||
let message = error.localizedDescription.lowercased()
|
||||
let nsError = error as NSError
|
||||
let isTLSFailure = nsError.domain == NSURLErrorDomain && [NSURLErrorSecureConnectionFailed, NSURLErrorServerCertificateHasBadDate, NSURLErrorServerCertificateUntrusted, NSURLErrorServerCertificateHasUnknownRoot, NSURLErrorServerCertificateNotYetValid].contains(nsError.code)
|
||||
|| message.contains("tls") || message.contains("secure connection")
|
||||
guard isTLSFailure else { throw error }
|
||||
return try await fetch(useProxy: false)
|
||||
}
|
||||
}
|
||||
|
||||
func fileUpload(url: URL, parentID: String? = nil, contentType: String = "application/octet-stream", chunkSize: Int = 5 * 1024 * 1024) async throws -> JSONValue {
|
||||
@@ -360,18 +381,18 @@ final class GuangyaAPI: @unchecked Sendable {
|
||||
return try await request(Self.accountBase.appendingPathComponent(String(path.dropFirst())), method: method, body: body, headers: headers, refreshOnUnauthorized: false)
|
||||
}
|
||||
|
||||
private func apiRequest(_ path: String, method: String = "POST", body: JSONValue? = nil, multipart: [(String, String, String, Data)]? = nil) async throws -> JSONValue {
|
||||
private func apiRequest(_ path: String, method: String = "POST", body: JSONValue? = nil, multipart: [(String, String, String, Data)]? = nil, allowedCodes: Set<Int> = []) async throws -> JSONValue {
|
||||
if let expiry = tokenExpiresAt, Date() >= expiry, refreshTokenValue != nil { try await refreshOrThrowAuthorizationExpired() }
|
||||
var headers = ["traceparent": Self.generateTraceparent()]
|
||||
if !accessToken.isEmpty { headers["authorization"] = "Bearer \(accessToken)" }
|
||||
return try await request(Self.apiBase.appendingPathComponent(String(path.dropFirst())), method: method, body: body, headers: headers, multipart: multipart)
|
||||
return try await request(Self.apiBase.appendingPathComponent(String(path.dropFirst())), method: method, body: body, headers: headers, multipart: multipart, allowedCodes: allowedCodes)
|
||||
}
|
||||
|
||||
private func publicPost(_ path: String, body: JSONValue) async throws -> JSONValue {
|
||||
try await request(Self.apiBase.appendingPathComponent(String(path.dropFirst())), method: "POST", body: body, headers: ["traceparent": Self.generateTraceparent()], refreshOnUnauthorized: false)
|
||||
}
|
||||
|
||||
private func request(_ url: URL, method: String, body: JSONValue?, headers: [String: String], multipart: [(String, String, String, Data)]? = nil, refreshOnUnauthorized: Bool = true, retryCount: Int = 2) async throws -> JSONValue {
|
||||
private func request(_ url: URL, method: String, body: JSONValue?, headers: [String: String], multipart: [(String, String, String, Data)]? = nil, refreshOnUnauthorized: Bool = true, retryCount: Int = 2, allowedCodes: Set<Int> = []) async throws -> JSONValue {
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
request.timeoutInterval = multipart == nil ? 45 : 120
|
||||
@@ -388,7 +409,7 @@ final class GuangyaAPI: @unchecked Sendable {
|
||||
} catch {
|
||||
guard retryCount > 0, Self.isTransientNetworkError(error) else { throw error }
|
||||
try await Task.sleep(for: .seconds(Double(3 - retryCount) * 0.9 + Double.random(in: 0.15...0.55)))
|
||||
return try await self.request(url, method: method, body: body, headers: headers, multipart: multipart, refreshOnUnauthorized: refreshOnUnauthorized, retryCount: retryCount - 1)
|
||||
return try await self.request(url, method: method, body: body, headers: headers, multipart: multipart, refreshOnUnauthorized: refreshOnUnauthorized, retryCount: retryCount - 1, allowedCodes: allowedCodes)
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse else { throw GuangyaAPIError.invalidResponse }
|
||||
if http.statusCode == 401, refreshOnUnauthorized {
|
||||
@@ -400,13 +421,13 @@ final class GuangyaAPI: @unchecked Sendable {
|
||||
// Transport failures and temporary account-service errors remain visible.
|
||||
throw error
|
||||
}
|
||||
return try await self.request(url, method: method, body: body, headers: headers.merging(["authorization": "Bearer \(accessToken)"]) { _, new in new }, multipart: multipart, refreshOnUnauthorized: false)
|
||||
return try await self.request(url, method: method, body: body, headers: headers.merging(["authorization": "Bearer \(accessToken)"]) { _, new in new }, multipart: multipart, refreshOnUnauthorized: false, allowedCodes: allowedCodes)
|
||||
}
|
||||
guard (200..<300).contains(http.statusCode) else {
|
||||
if retryCount > 0, [408, 429, 500, 502, 503, 504].contains(http.statusCode) {
|
||||
let retryAfter = Double(http.value(forHTTPHeaderField: "Retry-After") ?? "") ?? Double(3 - retryCount)
|
||||
try await Task.sleep(for: .seconds(max(0.8, min(retryAfter + Double.random(in: 0.1...0.5), 15))))
|
||||
return try await self.request(url, method: method, body: body, headers: headers, multipart: multipart, refreshOnUnauthorized: refreshOnUnauthorized, retryCount: retryCount - 1)
|
||||
return try await self.request(url, method: method, body: body, headers: headers, multipart: multipart, refreshOnUnauthorized: refreshOnUnauthorized, retryCount: retryCount - 1, allowedCodes: allowedCodes)
|
||||
}
|
||||
let message: String
|
||||
if let decoded = try? JSONDecoder().decode(JSONValue.self, from: data) {
|
||||
@@ -418,17 +439,19 @@ final class GuangyaAPI: @unchecked Sendable {
|
||||
}
|
||||
if data.isEmpty { return .object([:]) }
|
||||
if let decoded = try? JSONDecoder().decode(JSONValue.self, from: data) {
|
||||
if let code = decoded.firstIntDeep(["code"]), code != 0 {
|
||||
if let code = decoded.firstIntDeep(["code"]), code != 0, !allowedCodes.contains(code) {
|
||||
// Some API gateways put an expired-token 401 in the JSON body
|
||||
// while keeping the HTTP response status at 200.
|
||||
if code == 401, refreshOnUnauthorized {
|
||||
// The API uses 117 for an invalid or expired bearer token on
|
||||
// some resource endpoints, while other endpoints use 401.
|
||||
if [401, 117].contains(code), refreshOnUnauthorized {
|
||||
guard refreshTokenValue != nil else { throw GuangyaAPIError.authorizationExpired }
|
||||
do {
|
||||
try await refreshOrThrowAuthorizationExpired()
|
||||
} catch {
|
||||
throw error
|
||||
}
|
||||
return try await self.request(url, method: method, body: body, headers: headers.merging(["authorization": "Bearer \(accessToken)"]) { _, new in new }, multipart: multipart, refreshOnUnauthorized: false)
|
||||
return try await self.request(url, method: method, body: body, headers: headers.merging(["authorization": "Bearer \(accessToken)"]) { _, new in new }, multipart: multipart, refreshOnUnauthorized: false, allowedCodes: allowedCodes)
|
||||
}
|
||||
throw GuangyaAPIError.http(status: code, message: decoded.firstStringDeep(["msg", "message"]) ?? "接口返回错误")
|
||||
}
|
||||
@@ -515,4 +538,18 @@ final class GuangyaAPI: @unchecked Sendable {
|
||||
private static func generateTraceparent() -> String { "00-\(randomHex(16))-\(randomHex(8))-01" }
|
||||
private static func httpDate() -> String { let formatter = DateFormatter(); formatter.locale = Locale(identifier: "en_US_POSIX"); formatter.timeZone = TimeZone(secondsFromGMT: 0); formatter.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"; return formatter.string(from: Date()) }
|
||||
private static func calculateGCID(data: Data) -> String { let chunkSize = data.count <= 0x8000000 ? 262144 : data.count <= 0x10000000 ? 524288 : data.count <= 0x20000000 ? 1048576 : 2097152; var hashes = Data(); var offset = 0; while offset < data.count { let end = min(offset + chunkSize, data.count); hashes.append(contentsOf: Insecure.SHA1.hash(data: data.subdata(in: offset..<end))); offset = end }; return Insecure.SHA1.hash(data: hashes).map { String(format: "%02X", $0) }.joined() }
|
||||
|
||||
private static func base64MD5(_ value: String) -> String {
|
||||
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard normalized.range(of: "^[A-Fa-f0-9]{32}$", options: .regularExpression) != nil else { return normalized }
|
||||
var bytes = Data()
|
||||
var index = normalized.startIndex
|
||||
while index < normalized.endIndex {
|
||||
let next = normalized.index(index, offsetBy: 2)
|
||||
guard let byte = UInt8(normalized[index..<next], radix: 16) else { return normalized }
|
||||
bytes.append(byte)
|
||||
index = next
|
||||
}
|
||||
return bytes.base64EncodedString()
|
||||
}
|
||||
}
|
||||
|
||||
+1952
-147
File diff suppressed because it is too large
Load Diff
@@ -3,8 +3,22 @@ import SQLite3
|
||||
|
||||
enum MediaLibraryStoreError: LocalizedError {
|
||||
case writeFailed
|
||||
case backupFailed
|
||||
|
||||
var errorDescription: String? { "影视库数据库写入失败,未开始云端写回" }
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .writeFailed: return "影视库数据库写入失败,未开始云端写回"
|
||||
case .backupFailed: return "影视库缓存备份文件无效或无法写入"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaLibraryStatistics: Sendable, Equatable {
|
||||
var total = 0
|
||||
var movies = 0
|
||||
var series = 0
|
||||
var unmatched = 0
|
||||
var collections = 0
|
||||
}
|
||||
|
||||
actor MediaLibraryStore {
|
||||
@@ -31,6 +45,7 @@ actor MediaLibraryStore {
|
||||
root_path TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
recursive INTEGER NOT NULL DEFAULT 1,
|
||||
minimum_size_mb INTEGER NOT NULL DEFAULT 50,
|
||||
updated_at REAL
|
||||
);
|
||||
""")
|
||||
@@ -78,7 +93,10 @@ actor MediaLibraryStore {
|
||||
Self.execute(database, "ALTER TABLE media_items ADD COLUMN has_chinese_subtitle INTEGER NOT NULL DEFAULT 0;")
|
||||
Self.execute(database, "ALTER TABLE media_items ADD COLUMN collection_id INTEGER;")
|
||||
Self.execute(database, "ALTER TABLE media_items ADD COLUMN collection_name TEXT;")
|
||||
Self.execute(database, "ALTER TABLE media_libraries ADD COLUMN minimum_size_mb INTEGER NOT NULL DEFAULT 50;")
|
||||
Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_media_items_library_kind ON media_items(library_id, media_kind);")
|
||||
Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_media_items_library_title ON media_items(library_id, title COLLATE NOCASE);")
|
||||
Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_media_items_title ON media_items(title COLLATE NOCASE);")
|
||||
Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_media_items_gcid ON media_items(gcid);")
|
||||
Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_media_items_collection ON media_items(library_id, collection_id);")
|
||||
Self.execute(database, "CREATE INDEX IF NOT EXISTS idx_media_library_sources_library ON media_library_sources(library_id, sort_order);")
|
||||
@@ -86,20 +104,51 @@ actor MediaLibraryStore {
|
||||
|
||||
deinit { if let database { sqlite3_close(database) } }
|
||||
|
||||
func isEmpty() -> Bool {
|
||||
guard let database else { return true }
|
||||
return Self.scalarCount(database, sql: "SELECT COUNT(*) FROM media_libraries;") == 0 &&
|
||||
Self.scalarCount(database, sql: "SELECT COUNT(*) FROM media_items;") == 0
|
||||
}
|
||||
|
||||
func exportBackup(to url: URL) throws {
|
||||
guard let database else { throw MediaLibraryStoreError.backupFailed }
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
var destination: OpaquePointer?
|
||||
guard sqlite3_open_v2(url.path, &destination, SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK,
|
||||
let destination else { throw MediaLibraryStoreError.backupFailed }
|
||||
defer { sqlite3_close(destination) }
|
||||
guard let backup = sqlite3_backup_init(destination, "main", database, "main") else { throw MediaLibraryStoreError.backupFailed }
|
||||
defer { sqlite3_backup_finish(backup) }
|
||||
guard sqlite3_backup_step(backup, -1) == SQLITE_DONE else { throw MediaLibraryStoreError.backupFailed }
|
||||
}
|
||||
|
||||
func importBackup(from url: URL) throws {
|
||||
guard let database else { throw MediaLibraryStoreError.backupFailed }
|
||||
var source: OpaquePointer?
|
||||
guard sqlite3_open_v2(url.path, &source, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, nil) == SQLITE_OK,
|
||||
let source else { throw MediaLibraryStoreError.backupFailed }
|
||||
defer { sqlite3_close(source) }
|
||||
guard let backup = sqlite3_backup_init(database, "main", source, "main") else { throw MediaLibraryStoreError.backupFailed }
|
||||
defer { sqlite3_backup_finish(backup) }
|
||||
guard sqlite3_backup_step(backup, -1) == SQLITE_DONE else { throw MediaLibraryStoreError.backupFailed }
|
||||
Self.execute(database, "PRAGMA foreign_keys=ON;")
|
||||
}
|
||||
|
||||
func libraries() -> [MediaLibraryDefinition] {
|
||||
guard let database else { return [] }
|
||||
var statement: OpaquePointer?
|
||||
let sql = "SELECT id, name, root_id, root_path, kind, recursive, updated_at FROM media_libraries ORDER BY name COLLATE NOCASE;"
|
||||
let sql = "SELECT id, name, root_id, root_path, kind, recursive, minimum_size_mb, updated_at FROM media_libraries ORDER BY name COLLATE NOCASE;"
|
||||
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return [] }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
var values: [MediaLibraryDefinition] = []
|
||||
while sqlite3_step(statement) == SQLITE_ROW {
|
||||
guard let id = text(statement, 0), let name = text(statement, 1), let rootPath = text(statement, 3),
|
||||
let rawKind = text(statement, 4), let kind = MediaLibraryKind(rawValue: rawKind) else { continue }
|
||||
let epoch = integer(statement, 6)
|
||||
let minimumSizeMB = Int(integer(statement, 6) ?? 50)
|
||||
let epoch = integer(statement, 7)
|
||||
let sources = sources(libraryID: id)
|
||||
let resolvedSources = sources.isEmpty ? [MediaLibrarySource(id: "\(id)-legacy", rootID: text(statement, 2), path: rootPath)] : sources
|
||||
values.append(MediaLibraryDefinition(id: id, name: name, sources: resolvedSources, kind: kind, recursive: sqlite3_column_int(statement, 5) != 0, updatedAt: epoch.map { Date(timeIntervalSince1970: TimeInterval($0)) }))
|
||||
values.append(MediaLibraryDefinition(id: id, name: name, sources: resolvedSources, kind: kind, recursive: sqlite3_column_int(statement, 5) != 0, minimumSizeMB: minimumSizeMB, updatedAt: epoch.map { Date(timeIntervalSince1970: TimeInterval($0)) }))
|
||||
}
|
||||
return values
|
||||
}
|
||||
@@ -108,11 +157,11 @@ actor MediaLibraryStore {
|
||||
guard let database else { return }
|
||||
var statement: OpaquePointer?
|
||||
let sql = """
|
||||
INSERT INTO media_libraries (id, name, root_id, root_path, kind, recursive, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
INSERT INTO media_libraries (id, name, root_id, root_path, kind, recursive, minimum_size_mb, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(id) DO UPDATE SET name=excluded.name, root_id=excluded.root_id,
|
||||
root_path=excluded.root_path, kind=excluded.kind, recursive=excluded.recursive,
|
||||
updated_at=excluded.updated_at;
|
||||
minimum_size_mb=excluded.minimum_size_mb, updated_at=excluded.updated_at;
|
||||
"""
|
||||
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
@@ -120,7 +169,8 @@ actor MediaLibraryStore {
|
||||
bind(library.id, statement, 1); bind(library.name, statement, 2); bind(primarySource?.rootID, statement, 3)
|
||||
bind(primarySource?.path ?? "未配置目录", statement, 4); bind(library.kind.rawValue, statement, 5)
|
||||
sqlite3_bind_int(statement, 6, library.recursive ? 1 : 0)
|
||||
if let updatedAt = library.updatedAt { sqlite3_bind_double(statement, 7, updatedAt.timeIntervalSince1970) } else { sqlite3_bind_null(statement, 7) }
|
||||
sqlite3_bind_int(statement, 7, Int32(library.minimumSizeMB))
|
||||
if let updatedAt = library.updatedAt { sqlite3_bind_double(statement, 8, updatedAt.timeIntervalSince1970) } else { sqlite3_bind_null(statement, 8) }
|
||||
sqlite3_step(statement)
|
||||
replaceSources(library.sources, libraryID: library.id)
|
||||
}
|
||||
@@ -135,17 +185,122 @@ actor MediaLibraryStore {
|
||||
}
|
||||
|
||||
func items(libraryID: String) -> [MediaLibraryItem] {
|
||||
loadItems(libraryID: libraryID, limit: nil, offset: 0, includeBackdrop: true)
|
||||
}
|
||||
|
||||
/// Poster-wall reads deliberately omit full backdrops. Background artwork
|
||||
/// is substantially larger than a poster and decoding every row before the
|
||||
/// first view appears made even indexed libraries feel slow.
|
||||
func itemPage(libraryID: String?, limit: Int, offset: Int) -> [MediaLibraryItem] {
|
||||
loadItems(libraryID: libraryID, limit: max(1, limit), offset: max(0, offset), includeBackdrop: false)
|
||||
}
|
||||
|
||||
/// Migrates cache entries created before zero-padded disc streams were
|
||||
/// filtered at scan time. This intentionally reads only IDs and names — no
|
||||
/// poster/backdrop BLOBs are materialised during cleanup.
|
||||
func removeZeroPaddedMediaEntries() -> [(libraryID: String, fileID: String)] {
|
||||
guard let database else { return [] }
|
||||
var select: OpaquePointer?
|
||||
let sql = "SELECT library_id, file_id, cloud_name FROM media_items WHERE file_type = 2;"
|
||||
guard sqlite3_prepare_v2(database, sql, -1, &select, nil) == SQLITE_OK, let select else { return [] }
|
||||
defer { sqlite3_finalize(select) }
|
||||
var values: [(libraryID: String, fileID: String)] = []
|
||||
while sqlite3_step(select) == SQLITE_ROW {
|
||||
guard let libraryID = text(select, 0), let fileID = text(select, 1), let name = text(select, 2) else { continue }
|
||||
let stem = (name as NSString).deletingPathExtension
|
||||
if stem.range(of: "^0{2,}\\d+$", options: .regularExpression) != nil {
|
||||
values.append((libraryID, fileID))
|
||||
}
|
||||
}
|
||||
guard !values.isEmpty else { return [] }
|
||||
Self.execute(database, "BEGIN IMMEDIATE TRANSACTION;")
|
||||
var delete: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(database, "DELETE FROM media_items WHERE library_id = ?1 AND file_id = ?2;", -1, &delete, nil) == SQLITE_OK, let delete else {
|
||||
Self.execute(database, "ROLLBACK;")
|
||||
return []
|
||||
}
|
||||
defer { sqlite3_finalize(delete) }
|
||||
for value in values {
|
||||
sqlite3_reset(delete); sqlite3_clear_bindings(delete)
|
||||
bind(value.libraryID, delete, 1); bind(value.fileID, delete, 2)
|
||||
guard sqlite3_step(delete) == SQLITE_DONE else { Self.execute(database, "ROLLBACK;"); return [] }
|
||||
}
|
||||
return Self.execute(database, "COMMIT;") ? values : []
|
||||
}
|
||||
|
||||
/// Lightweight aggregation for navigation badges. It touches only indexed
|
||||
/// scalar columns and never materialises media rows or artwork BLOBs.
|
||||
func statistics() -> MediaLibraryStatistics {
|
||||
guard let database else { return .init() }
|
||||
var statement: OpaquePointer?
|
||||
let sql = """
|
||||
SELECT COUNT(*),
|
||||
COALESCE(SUM(CASE WHEN media_kind = 'movie' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN media_kind = 'tv' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN tmdb_id IS NULL THEN 1 ELSE 0 END), 0),
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN collection_id IS NOT NULL THEN 'id:' || collection_id
|
||||
WHEN collection_name IS NOT NULL AND collection_name <> '' THEN 'name:' || collection_name
|
||||
END)
|
||||
FROM media_items;
|
||||
"""
|
||||
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return .init() }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
guard sqlite3_step(statement) == SQLITE_ROW else { return .init() }
|
||||
return MediaLibraryStatistics(
|
||||
total: Int(sqlite3_column_int64(statement, 0)),
|
||||
movies: Int(sqlite3_column_int64(statement, 1)),
|
||||
series: Int(sqlite3_column_int64(statement, 2)),
|
||||
unmatched: Int(sqlite3_column_int64(statement, 3)),
|
||||
collections: Int(sqlite3_column_int64(statement, 4))
|
||||
)
|
||||
}
|
||||
|
||||
func statisticsByLibraryID() -> [String: MediaLibraryStatistics] {
|
||||
guard let database else { return [:] }
|
||||
var statement: OpaquePointer?
|
||||
let sql = """
|
||||
SELECT library_id, COUNT(*),
|
||||
COALESCE(SUM(CASE WHEN media_kind = 'movie' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN media_kind = 'tv' THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN tmdb_id IS NULL THEN 1 ELSE 0 END), 0),
|
||||
COUNT(DISTINCT CASE
|
||||
WHEN collection_id IS NOT NULL THEN 'id:' || collection_id
|
||||
WHEN collection_name IS NOT NULL AND collection_name <> '' THEN 'name:' || collection_name
|
||||
END)
|
||||
FROM media_items GROUP BY library_id;
|
||||
"""
|
||||
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return [:] }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
var values: [String: MediaLibraryStatistics] = [:]
|
||||
while sqlite3_step(statement) == SQLITE_ROW {
|
||||
guard let libraryID = text(statement, 0) else { continue }
|
||||
values[libraryID] = MediaLibraryStatistics(
|
||||
total: Int(sqlite3_column_int64(statement, 1)),
|
||||
movies: Int(sqlite3_column_int64(statement, 2)),
|
||||
series: Int(sqlite3_column_int64(statement, 3)),
|
||||
unmatched: Int(sqlite3_column_int64(statement, 4)),
|
||||
collections: Int(sqlite3_column_int64(statement, 5))
|
||||
)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
private func loadItems(libraryID: String?, limit: Int?, offset: Int, includeBackdrop: Bool) -> [MediaLibraryItem] {
|
||||
guard let database else { return [] }
|
||||
var statement: OpaquePointer?
|
||||
let scope = libraryID == nil ? "" : "WHERE library_id = ?1"
|
||||
let backdropColumn = includeBackdrop ? "backdrop" : "NULL AS backdrop"
|
||||
let pagination = limit.map { " LIMIT \($0) OFFSET \(offset)" } ?? ""
|
||||
let sql = """
|
||||
SELECT file_id, resource_path, cloud_name, file_size, gcid, file_type, tmdb_id,
|
||||
media_kind, title, original_title, release_date, overview, poster, backdrop,
|
||||
media_kind, title, original_title, release_date, overview, poster, \(backdropColumn),
|
||||
has_chinese_audio, has_chinese_subtitle, collection_id, collection_name
|
||||
FROM media_items WHERE library_id = ?1 ORDER BY title COLLATE NOCASE;
|
||||
FROM media_items \(scope) ORDER BY title COLLATE NOCASE\(pagination);
|
||||
"""
|
||||
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return [] }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
bind(libraryID, statement, 1)
|
||||
if let libraryID { bind(libraryID, statement, 1) }
|
||||
var values: [MediaLibraryItem] = []
|
||||
while sqlite3_step(statement) == SQLITE_ROW {
|
||||
guard let fileID = text(statement, 0), let path = text(statement, 1), let name = text(statement, 2),
|
||||
@@ -246,6 +401,13 @@ actor MediaLibraryStore {
|
||||
private func bind(_ value: String?, _ statement: OpaquePointer, _ index: Int32) {
|
||||
if let value { sqlite3_bind_text(statement, index, value, -1, transient) } else { sqlite3_bind_null(statement, index) }
|
||||
}
|
||||
|
||||
private static func scalarCount(_ database: OpaquePointer, sql: String) -> Int {
|
||||
var statement: OpaquePointer?
|
||||
guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK, let statement else { return 0 }
|
||||
defer { sqlite3_finalize(statement) }
|
||||
return sqlite3_step(statement) == SQLITE_ROW ? Int(sqlite3_column_int64(statement, 0)) : 0
|
||||
}
|
||||
private func bind(_ value: Int64?, _ statement: OpaquePointer, _ index: Int32) {
|
||||
if let value { sqlite3_bind_int64(statement, index, value) } else { sqlite3_bind_null(statement, index) }
|
||||
}
|
||||
|
||||
+284
-4
@@ -72,6 +72,22 @@ enum JSONValue: Codable, Sendable, Equatable {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Resource IDs are returned as either strings or JSON numbers by different
|
||||
/// file endpoints. Preserve the integer representation when walking nested
|
||||
/// payloads so parent comparisons do not trigger a same-folder move.
|
||||
func firstIDDeep(_ keys: [String]) -> String? {
|
||||
if let value = firstID(keys) { return value }
|
||||
guard let object = objectValue else { return nil }
|
||||
let preferredKeys = ["data", "result", "user", "profile", "payload"]
|
||||
for key in preferredKeys {
|
||||
if let value = object[key]?.firstIDDeep(keys) { return value }
|
||||
}
|
||||
for (key, value) in object where !preferredKeys.contains(key) {
|
||||
if let found = value.firstIDDeep(keys) { return found }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// API responses are not consistent about whether payload fields are nested in `data`.
|
||||
func firstStringDeep(_ keys: [String]) -> String? {
|
||||
if let value = firstString(keys) { return value }
|
||||
@@ -181,6 +197,19 @@ struct CloudFile: Identifiable, Hashable, Sendable {
|
||||
return formatter.string(fromByteCount: size)
|
||||
}
|
||||
|
||||
init(id: String, name: String, isDirectory: Bool, size: Int64? = nil, gcid: String? = nil, subDirectoryCount: Int? = nil, subFileCount: Int? = nil, modifiedAt: String = "", cloudPath: String = "", fileType: Int = 0) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.isDirectory = isDirectory
|
||||
self.size = size
|
||||
self.gcid = gcid
|
||||
self.subDirectoryCount = subDirectoryCount
|
||||
self.subFileCount = subFileCount
|
||||
self.modifiedAt = modifiedAt
|
||||
self.cloudPath = cloudPath
|
||||
self.fileType = fileType
|
||||
}
|
||||
|
||||
init?(json: JSONValue) {
|
||||
guard let object = json.objectValue,
|
||||
let name = json.firstString(["name", "fileName", "resName"]),
|
||||
@@ -241,6 +270,85 @@ struct FolderPath: Identifiable, Hashable, Sendable {
|
||||
let name: String
|
||||
}
|
||||
|
||||
struct FastTransferEntry: Identifiable, Hashable, Sendable, Codable {
|
||||
let id: UUID
|
||||
let path: String
|
||||
let size: Int64
|
||||
let etag: String?
|
||||
let gcid: String?
|
||||
|
||||
init(id: UUID = UUID(), path: String, size: Int64, etag: String? = nil, gcid: String? = nil) {
|
||||
self.id = id
|
||||
self.path = path
|
||||
self.size = size
|
||||
self.etag = etag
|
||||
self.gcid = gcid
|
||||
}
|
||||
|
||||
var name: String { (path as NSString).lastPathComponent }
|
||||
var directoryPath: String { (path as NSString).deletingLastPathComponent }
|
||||
var hashKind: String { etag?.isEmpty == false ? "MD5" : "GCID" }
|
||||
}
|
||||
|
||||
struct FastTransferProgress: Sendable, Codable {
|
||||
var total = 0
|
||||
var completed = 0
|
||||
var imported = 0
|
||||
var skipped = 0
|
||||
var failed = 0
|
||||
var cancelled = 0
|
||||
var currentName = ""
|
||||
/// Entries undergoing target-folder preparation or an in-flight flash upload.
|
||||
/// Keeping this in the progress model lets the queue show useful work before
|
||||
/// a terminal result exists (for example while checking duplicate names).
|
||||
var activeEntryIDs: Set<UUID> = []
|
||||
|
||||
var fraction: Double { total == 0 ? 0 : Double(completed + failed + cancelled) / Double(total) }
|
||||
var processed: Int { completed + failed + cancelled }
|
||||
}
|
||||
|
||||
struct FastTransferResult: Identifiable, Sendable, Codable {
|
||||
let id: UUID
|
||||
let entry: FastTransferEntry
|
||||
let state: State
|
||||
let message: String
|
||||
let createdAt: Date
|
||||
let taskID: String?
|
||||
let targetID: String?
|
||||
let details: [String]
|
||||
/// Points to the failed record this attempt was manually retried from.
|
||||
let retryOf: UUID?
|
||||
|
||||
enum State: String, Sendable, Codable {
|
||||
case imported
|
||||
case skipped
|
||||
case failed
|
||||
case cancelled
|
||||
}
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
entry: FastTransferEntry,
|
||||
state: State,
|
||||
message: String,
|
||||
createdAt: Date = Date(),
|
||||
taskID: String?,
|
||||
targetID: String?,
|
||||
details: [String],
|
||||
retryOf: UUID?
|
||||
) {
|
||||
self.id = id
|
||||
self.entry = entry
|
||||
self.state = state
|
||||
self.message = message
|
||||
self.createdAt = createdAt
|
||||
self.taskID = taskID
|
||||
self.targetID = targetID
|
||||
self.details = details
|
||||
self.retryOf = retryOf
|
||||
}
|
||||
}
|
||||
|
||||
struct UserProfile: Sendable {
|
||||
let name: String
|
||||
let phone: String
|
||||
@@ -519,7 +627,10 @@ struct MediaLibrarySource: Identifiable, Hashable, Sendable {
|
||||
enum MediaLibraryDestination: Equatable, Sendable {
|
||||
case all
|
||||
case library(String)
|
||||
case management
|
||||
case categories
|
||||
case tasks
|
||||
case tmdb
|
||||
}
|
||||
|
||||
enum MediaLibraryKind: String, CaseIterable, Identifiable, Codable, Sendable {
|
||||
@@ -544,12 +655,62 @@ enum MediaLibraryKind: String, CaseIterable, Identifiable, Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaCategoryRule: Identifiable, Hashable, Codable, Sendable {
|
||||
let id: String
|
||||
var name: String
|
||||
var mediaKind: TMDBMediaKind
|
||||
var genreIDs: [Int]
|
||||
var languages: [String]
|
||||
var isFallback: Bool
|
||||
|
||||
init(id: String = UUID().uuidString, name: String, mediaKind: TMDBMediaKind, genreIDs: [Int] = [], languages: [String] = [], isFallback: Bool = false) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.mediaKind = mediaKind
|
||||
self.genreIDs = genreIDs
|
||||
self.languages = languages
|
||||
self.isFallback = isFallback
|
||||
}
|
||||
|
||||
/// Library organization is intentionally based on TMDB's original language.
|
||||
/// Genre data is retained for compatibility with saved rules, but cannot
|
||||
/// redirect a title into a category.
|
||||
func matches(mediaKind: TMDBMediaKind, genreIDs _: Set<Int>, language: String) -> Bool {
|
||||
guard self.mediaKind == mediaKind else { return false }
|
||||
if isFallback { return true }
|
||||
return !languages.isEmpty && languages.contains(language.lowercased())
|
||||
}
|
||||
|
||||
static let defaults: [MediaCategoryRule] = [
|
||||
MediaCategoryRule(id: "movie-chinese", name: "国产电影", mediaKind: .movie, languages: ["zh", "cn", "yue"]),
|
||||
MediaCategoryRule(id: "movie-japanese", name: "日韩电影", mediaKind: .movie, languages: ["ja", "ko", "th"]),
|
||||
MediaCategoryRule(id: "movie-western", name: "欧美电影", mediaKind: .movie, languages: ["en"]),
|
||||
MediaCategoryRule(id: "movie-other", name: "其他电影", mediaKind: .movie, isFallback: true),
|
||||
MediaCategoryRule(id: "tv-chinese", name: "国产剧集", mediaKind: .tv, languages: ["zh", "cn", "yue"]),
|
||||
MediaCategoryRule(id: "tv-japanese", name: "日韩剧集", mediaKind: .tv, languages: ["ja", "ko", "th"]),
|
||||
MediaCategoryRule(id: "tv-western", name: "欧美剧集", mediaKind: .tv, languages: ["en"]),
|
||||
MediaCategoryRule(id: "tv-other", name: "其他剧集", mediaKind: .tv, isFallback: true)
|
||||
]
|
||||
|
||||
static func load() -> [MediaCategoryRule] {
|
||||
guard let data = UserDefaults.standard.data(forKey: "guangya.mediaCategoryRules"),
|
||||
let rules = try? JSONDecoder().decode([MediaCategoryRule].self, from: data), !rules.isEmpty else { return defaults }
|
||||
return rules
|
||||
}
|
||||
|
||||
static func save(_ rules: [MediaCategoryRule]) {
|
||||
guard let data = try? JSONEncoder().encode(rules) else { return }
|
||||
UserDefaults.standard.set(data, forKey: "guangya.mediaCategoryRules")
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaLibraryDefinition: Identifiable, Hashable, Sendable {
|
||||
let id: String
|
||||
var name: String
|
||||
var sources: [MediaLibrarySource]
|
||||
var kind: MediaLibraryKind
|
||||
var recursive: Bool
|
||||
var minimumSizeMB: Int
|
||||
var updatedAt: Date?
|
||||
|
||||
var rootID: String? { sources.first?.rootID }
|
||||
@@ -558,28 +719,147 @@ struct MediaLibraryDefinition: Identifiable, Hashable, Sendable {
|
||||
return sources.isEmpty ? "未配置目录" : "\(sources.count) 个媒体目录"
|
||||
}
|
||||
|
||||
init(id: String, name: String, sources: [MediaLibrarySource], kind: MediaLibraryKind, recursive: Bool, updatedAt: Date?) {
|
||||
init(id: String, name: String, sources: [MediaLibrarySource], kind: MediaLibraryKind, recursive: Bool, minimumSizeMB: Int = 50, updatedAt: Date?) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.sources = sources
|
||||
self.kind = kind
|
||||
self.recursive = recursive
|
||||
self.minimumSizeMB = max(0, minimumSizeMB)
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
init(id: String, name: String, rootID: String?, rootPath: String, kind: MediaLibraryKind, recursive: Bool, updatedAt: Date?) {
|
||||
self.init(id: id, name: name, sources: [MediaLibrarySource(rootID: rootID, path: rootPath)], kind: kind, recursive: recursive, updatedAt: updatedAt)
|
||||
init(id: String, name: String, rootID: String?, rootPath: String, kind: MediaLibraryKind, recursive: Bool, minimumSizeMB: Int = 50, updatedAt: Date?) {
|
||||
self.init(id: id, name: name, sources: [MediaLibrarySource(rootID: rootID, path: rootPath)], kind: kind, recursive: recursive, minimumSizeMB: minimumSizeMB, updatedAt: updatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaLibraryScanProgress: Sendable {
|
||||
struct MediaLibraryScanProgress: Codable, Sendable {
|
||||
var phase = ""
|
||||
var completed = 0
|
||||
var total = 0
|
||||
}
|
||||
|
||||
enum MediaLibraryTaskState: String, Codable, Sendable {
|
||||
case queued
|
||||
case scanning
|
||||
case writing
|
||||
case completed
|
||||
case cancelled
|
||||
case failed
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .queued: return "等待开始"
|
||||
case .scanning: return "正在刮削"
|
||||
case .writing: return "正在写回"
|
||||
case .completed: return "已完成"
|
||||
case .cancelled: return "已停止"
|
||||
case .failed: return "失败"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .queued: return "clock"
|
||||
case .scanning: return "magnifyingglass"
|
||||
case .writing: return "square.and.arrow.down"
|
||||
case .completed: return "checkmark.circle.fill"
|
||||
case .cancelled: return "stop.circle.fill"
|
||||
case .failed: return "exclamationmark.circle.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaLibraryTaskLog: Identifiable, Codable, Sendable {
|
||||
let id: UUID
|
||||
let date: Date
|
||||
let message: String
|
||||
|
||||
init(date: Date = Date(), message: String) {
|
||||
self.id = UUID()
|
||||
self.date = date
|
||||
self.message = message
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaLibraryTaskCheckpoint: Codable, Sendable {
|
||||
var discoveredFileIDs: Set<String>
|
||||
var completedFileIDs: Set<String>
|
||||
var writtenFileIDs: Set<String>
|
||||
|
||||
init(discoveredFileIDs: Set<String> = [], completedFileIDs: Set<String> = [], writtenFileIDs: Set<String> = []) {
|
||||
self.discoveredFileIDs = discoveredFileIDs
|
||||
self.completedFileIDs = completedFileIDs
|
||||
self.writtenFileIDs = writtenFileIDs
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case discoveredFileIDs
|
||||
case completedFileIDs
|
||||
case writtenFileIDs
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
discoveredFileIDs = try container.decodeIfPresent(Set<String>.self, forKey: .discoveredFileIDs) ?? []
|
||||
completedFileIDs = try container.decodeIfPresent(Set<String>.self, forKey: .completedFileIDs) ?? []
|
||||
writtenFileIDs = try container.decodeIfPresent(Set<String>.self, forKey: .writtenFileIDs) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaLibraryTask: Identifiable, Codable, Sendable {
|
||||
let id: UUID
|
||||
let libraryID: String
|
||||
let libraryName: String
|
||||
let startedAt: Date
|
||||
var updatedAt: Date
|
||||
var state: MediaLibraryTaskState
|
||||
var progress: MediaLibraryScanProgress
|
||||
var currentScrapingName: String?
|
||||
var currentStoringName: String?
|
||||
var currentWritingName: String?
|
||||
var failureReason: String?
|
||||
var logs: [MediaLibraryTaskLog]
|
||||
var checkpoint: MediaLibraryTaskCheckpoint?
|
||||
|
||||
init(library: MediaLibraryDefinition) {
|
||||
id = UUID()
|
||||
libraryID = library.id
|
||||
libraryName = library.name
|
||||
startedAt = Date()
|
||||
updatedAt = startedAt
|
||||
state = .queued
|
||||
progress = MediaLibraryScanProgress(phase: "等待开始")
|
||||
currentScrapingName = nil
|
||||
currentStoringName = nil
|
||||
currentWritingName = nil
|
||||
failureReason = nil
|
||||
logs = [MediaLibraryTaskLog(message: "任务已创建,等待扫描「\(library.name)」")]
|
||||
checkpoint = MediaLibraryTaskCheckpoint()
|
||||
}
|
||||
|
||||
var isActive: Bool { [.queued, .scanning, .writing].contains(state) }
|
||||
|
||||
static func loadHistory() -> [MediaLibraryTask] {
|
||||
guard let data = UserDefaults.standard.data(forKey: "guangya.mediaLibraryTaskHistory"),
|
||||
let tasks = try? JSONDecoder().decode([MediaLibraryTask].self, from: data) else { return [] }
|
||||
return tasks.map { task in
|
||||
var restored = task
|
||||
if restored.isActive {
|
||||
restored.state = .cancelled
|
||||
restored.progress.phase = "应用重启,任务未继续执行"
|
||||
restored.logs.append(MediaLibraryTaskLog(message: "应用重启,未完成任务已停止"))
|
||||
restored.updatedAt = Date()
|
||||
}
|
||||
return restored
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaSidecarFiles: Sendable {
|
||||
let parentID: String?
|
||||
let mediaFolderName: String?
|
||||
let nfo: CloudFile?
|
||||
let poster: CloudFile?
|
||||
let backdrop: CloudFile?
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum WorkspaceTool: String, CaseIterable, Identifiable {
|
||||
enum WorkspaceTool: String, CaseIterable, Identifiable, Hashable {
|
||||
case media = "影视库"
|
||||
case scan = "文件扫描与清理"
|
||||
case rename = "批量重命名"
|
||||
case fastTransfer = "秒传工具"
|
||||
case tmdb = "TMDB 整理"
|
||||
case settings = "工作区设置"
|
||||
|
||||
@@ -14,6 +15,7 @@ enum WorkspaceTool: String, CaseIterable, Identifiable {
|
||||
case .media: return "play.tv.fill"
|
||||
case .scan: return "magnifyingglass.circle.fill"
|
||||
case .rename: return "character.cursor.ibeam"
|
||||
case .fastTransfer: return "bolt.badge.clock"
|
||||
case .tmdb: return "film.stack"
|
||||
case .settings: return "gearshape"
|
||||
}
|
||||
@@ -23,12 +25,50 @@ enum WorkspaceTool: String, CaseIterable, Identifiable {
|
||||
case .media: return "海报墙、媒体详情与播放入口"
|
||||
case .scan: return "空文件夹、重复文件与相似文件夹统一扫描"
|
||||
case .rename: return "按字符、正则和组合规则批量修改名称"
|
||||
case .fastTransfer: return "导入秒传 JSON,或从本地文件生成 GCID JSON"
|
||||
case .tmdb: return "配置并执行媒体识别整理"
|
||||
case .settings: return "代理和本机工具配置"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum WorkspaceMode: String, CaseIterable, Identifiable {
|
||||
case files = "光鸭云盘"
|
||||
case media = "光鸭影视"
|
||||
|
||||
var id: String { rawValue }
|
||||
var icon: String { self == .files ? "folder" : "play.tv.fill" }
|
||||
}
|
||||
|
||||
private enum ToolbarSearchScope: Hashable {
|
||||
case files
|
||||
case media
|
||||
}
|
||||
|
||||
struct CompactHoverHint: ViewModifier {
|
||||
let title: String
|
||||
@State private var isHovering = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.onHover { isHovering = $0 }
|
||||
.popover(isPresented: $isHovering, arrowEdge: .leading) {
|
||||
Text(title)
|
||||
.font(.callout.weight(.medium))
|
||||
.padding(.horizontal, 12).padding(.vertical, 8)
|
||||
.fixedSize()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func compactHoverHint(_ title: String, enabled: Bool = true) -> some View {
|
||||
if enabled { modifier(CompactHoverHint(title: title)) }
|
||||
else { self }
|
||||
}
|
||||
}
|
||||
|
||||
struct WorkspaceView: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@State private var selectedFileIDs: Set<String> = []
|
||||
@@ -41,67 +81,126 @@ struct WorkspaceView: View {
|
||||
@State private var selectedTool: WorkspaceTool?
|
||||
@State private var folderName = ""
|
||||
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
||||
@State private var previewFile: CloudFile?
|
||||
@State private var globalSearchText = ""
|
||||
@State private var workspaceMode: WorkspaceMode = .files
|
||||
@State private var isCompactWindow = false
|
||||
@State private var sidebarWasAutoCollapsed = false
|
||||
@State private var retainedTools: Set<WorkspaceTool> = []
|
||||
@State private var hasVisitedMediaMode = false
|
||||
@State private var isToolbarSearchExpanded = false
|
||||
@State private var isDualPanel = false
|
||||
@State private var isWindowDropTarget = false
|
||||
@FocusState private var toolbarSearchFocus: ToolbarSearchScope?
|
||||
|
||||
private var selectedFile: CloudFile? {
|
||||
selectedFileIDs.count == 1 ? model.files.first { $0.id == selectedFileIDs.first } : nil
|
||||
guard selectedFileIDs.count == 1, let id = selectedFileIDs.first else { return nil }
|
||||
return (model.files + model.globalFileSearchResults).first { $0.id == id }
|
||||
}
|
||||
|
||||
private var dualPanelContent: AnyView? {
|
||||
guard isDualPanel, selectedTool == nil else { return nil }
|
||||
return AnyView(DualPanelView(model: model))
|
||||
}
|
||||
|
||||
private var filesBrowserContent: some View {
|
||||
FilesBrowser(
|
||||
model: model,
|
||||
selectedFileIDs: $selectedFileIDs,
|
||||
globalSearchText: $globalSearchText,
|
||||
renameFile: $renameFile,
|
||||
isCreatingFolder: $isCreatingFolder,
|
||||
isImporting: $isImporting,
|
||||
isShowingSettings: $isShowingSettings,
|
||||
isShowingDuplicates: $isShowingDuplicates,
|
||||
isDualPanel: $isDualPanel,
|
||||
onPreview: { PlayerWindowCoordinator.shared.open(model: model, file: $0) },
|
||||
onOpenTool: { selectedTool = $0 },
|
||||
onShowInspector: { isInspectorPresented = true },
|
||||
dualPanelContent: dualPanelContent
|
||||
)
|
||||
.frame(minWidth: 680, maxWidth: .infinity, maxHeight: .infinity)
|
||||
.inspector(isPresented: $isInspectorPresented) {
|
||||
FileInspector(
|
||||
model: model,
|
||||
file: selectedFile,
|
||||
onPreview: { PlayerWindowCoordinator.shared.open(model: model, file: $0) },
|
||||
onClose: { isInspectorPresented = false }
|
||||
)
|
||||
.inspectorColumnWidth(min: 280, ideal: 320, max: 420)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
AppBackdrop()
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
WorkspaceSidebar(model: model, selectedTool: $selectedTool, columnVisibility: $columnVisibility)
|
||||
.navigationSplitViewColumnWidth(min: 210, ideal: 238, max: 280)
|
||||
} detail: {
|
||||
if let selectedTool {
|
||||
ToolWorkspacePage(model: model, tool: selectedTool)
|
||||
} else {
|
||||
FilesBrowser(
|
||||
model: model,
|
||||
selectedFileIDs: $selectedFileIDs,
|
||||
globalSearchText: $globalSearchText,
|
||||
renameFile: $renameFile,
|
||||
isCreatingFolder: $isCreatingFolder,
|
||||
isImporting: $isImporting,
|
||||
isShowingSettings: $isShowingSettings,
|
||||
isShowingDuplicates: $isShowingDuplicates,
|
||||
onPreview: { previewFile = $0 },
|
||||
onOpenTool: { selectedTool = $0 },
|
||||
onShowInspector: { isInspectorPresented = true }
|
||||
)
|
||||
.frame(minWidth: 680, maxWidth: .infinity, maxHeight: .infinity)
|
||||
.inspector(isPresented: $isInspectorPresented) {
|
||||
FileInspector(
|
||||
model: model,
|
||||
file: selectedFile,
|
||||
onPreview: { previewFile = $0 },
|
||||
onClose: { isInspectorPresented = false }
|
||||
)
|
||||
.inspectorColumnWidth(min: 280, ideal: 320, max: 420)
|
||||
}
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
AppTitleToolbar(model: model, selectedTool: selectedTool, searchText: $globalSearchText)
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
if selectedTool == nil {
|
||||
Button { isInspectorPresented.toggle() } label: {
|
||||
Image(systemName: "sidebar.trailing")
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
AppBackdrop()
|
||||
ZStack {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
WorkspaceSidebar(model: model, selectedTool: $selectedTool, isCompact: isCompactWindow)
|
||||
.navigationSplitViewColumnWidth(min: isCompactWindow ? 64 : 210, ideal: isCompactWindow ? 64 : 238, max: isCompactWindow ? 64 : 280)
|
||||
} detail: {
|
||||
ZStack {
|
||||
// Always render FilesBrowser — it provides the
|
||||
// action bar (upload / toggle / refresh) and
|
||||
// breadcrumb. In dual-panel mode its file-list
|
||||
// content is replaced by DualPanelView.
|
||||
filesBrowserContent
|
||||
|
||||
ForEach(retainedToolList) { tool in
|
||||
ToolWorkspacePage(model: model, tool: tool)
|
||||
.opacity(selectedTool == tool ? 1 : 0)
|
||||
.allowsHitTesting(selectedTool == tool)
|
||||
.accessibilityHidden(selectedTool != tool)
|
||||
}
|
||||
}
|
||||
}
|
||||
.opacity(workspaceMode == .files ? 1 : 0)
|
||||
.allowsHitTesting(workspaceMode == .files)
|
||||
.accessibilityHidden(workspaceMode != .files)
|
||||
|
||||
if hasVisitedMediaMode || workspaceMode == .media {
|
||||
MediaLibraryPage(model: model)
|
||||
.opacity(workspaceMode == .media ? 1 : 0)
|
||||
.allowsHitTesting(workspaceMode == .media)
|
||||
.accessibilityHidden(workspaceMode != .media)
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(.hidden)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
WorkspaceModeSwitcher(selection: $workspaceMode, isCompact: isCompactWindow)
|
||||
}
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
HStack(spacing: 6) {
|
||||
if let scope = toolbarSearchScope {
|
||||
toolbarSearchControl(scope)
|
||||
}
|
||||
}
|
||||
.help(isInspectorPresented ? "隐藏详情检查器" : "显示详情检查器")
|
||||
.accessibilityLabel(isInspectorPresented ? "隐藏详情检查器" : "显示详情检查器")
|
||||
.keyboardShortcut("i", modifiers: [.command, .option])
|
||||
.disabled(selectedFile == nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onDrop(of: [UTType.fileURL], isTargeted: $isWindowDropTarget) { providers in
|
||||
for provider in providers {
|
||||
_ = provider.loadObject(ofClass: URL.self) { url, _ in
|
||||
guard let url else { return }
|
||||
Task { @MainActor in await model.upload(url: url) }
|
||||
}
|
||||
}
|
||||
return !providers.isEmpty
|
||||
}
|
||||
.overlay {
|
||||
if isWindowDropTarget {
|
||||
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||
.stroke(Color.orange, style: StrokeStyle(lineWidth: 3, dash: [8, 5]))
|
||||
.fill(Color.orange.opacity(0.12))
|
||||
.padding(10)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
.onAppear { updateCompactLayout(for: geometry.size.width) }
|
||||
.onChange(of: geometry.size.width) { _, width in updateCompactLayout(for: width) }
|
||||
}
|
||||
.frame(minWidth: 1040, minHeight: 700)
|
||||
.frame(minWidth: 860, minHeight: 640)
|
||||
.sheet(item: $renameFile) { file in RenameSheet(file: file) { name in Task { await model.rename(file, to: name) } } }
|
||||
.sheet(isPresented: $isCreatingFolder) {
|
||||
CreateFolderSheet(name: $folderName) { name in
|
||||
@@ -112,7 +211,6 @@ struct WorkspaceView: View {
|
||||
}
|
||||
.sheet(isPresented: $isShowingSettings) { WorkspaceSettings(model: model) }
|
||||
.sheet(isPresented: $isShowingDuplicates) { DuplicateResults(model: model) }
|
||||
.sheet(item: $previewFile) { file in FilePreviewSheet(model: model, file: file) }
|
||||
.fileImporter(isPresented: $isImporting, allowedContentTypes: [.item], allowsMultipleSelection: false) { result in
|
||||
switch result {
|
||||
case .success(let urls): if let url = urls.first { Task { await model.upload(url: url) } }
|
||||
@@ -126,13 +224,30 @@ struct WorkspaceView: View {
|
||||
if model.files.isEmpty && !model.isLoadingFiles { await model.refresh() }
|
||||
}
|
||||
.onChange(of: selectedTool) { _, tool in
|
||||
if tool != nil { isInspectorPresented = false }
|
||||
if let tool {
|
||||
retainedTools.insert(tool)
|
||||
isInspectorPresented = false
|
||||
}
|
||||
if tool != nil { collapseToolbarSearch(clearQuery: false) }
|
||||
}
|
||||
.onChange(of: workspaceMode) { _, mode in
|
||||
selectedTool = nil
|
||||
isInspectorPresented = false
|
||||
collapseToolbarSearch(clearQuery: false)
|
||||
if mode == .media {
|
||||
hasVisitedMediaMode = true
|
||||
} else if isCompactWindow {
|
||||
columnVisibility = .detailOnly
|
||||
sidebarWasAutoCollapsed = true
|
||||
}
|
||||
}
|
||||
.onChange(of: model.section) { _, _ in
|
||||
selectedFileIDs = []
|
||||
selectedTool = nil
|
||||
isInspectorPresented = false
|
||||
globalSearchText = ""
|
||||
model.clearGlobalFileSearch()
|
||||
collapseToolbarSearch(clearQuery: false)
|
||||
Task { await model.refresh() }
|
||||
}
|
||||
.onChange(of: model.files.map(\.id)) { _, ids in
|
||||
@@ -143,28 +258,134 @@ struct WorkspaceView: View {
|
||||
selectedFileIDs = []
|
||||
isInspectorPresented = false
|
||||
globalSearchText = ""
|
||||
model.clearGlobalFileSearch()
|
||||
}
|
||||
.onChange(of: model.globalFileSearchResults.map(\.id)) { _, ids in
|
||||
selectedFileIDs.formIntersection(Set(ids))
|
||||
if selectedFileIDs.isEmpty { isInspectorPresented = false }
|
||||
}
|
||||
}
|
||||
|
||||
private func updateCompactLayout(for width: CGFloat) {
|
||||
let compact = width < 1120
|
||||
guard compact != isCompactWindow else { return }
|
||||
isCompactWindow = compact
|
||||
guard workspaceMode == .files else { return }
|
||||
if sidebarWasAutoCollapsed { columnVisibility = .all; sidebarWasAutoCollapsed = false }
|
||||
}
|
||||
|
||||
private var retainedToolList: [WorkspaceTool] {
|
||||
let activeTool = selectedTool.map { retainedTools.union([$0]) } ?? retainedTools
|
||||
return WorkspaceTool.allCases.filter(activeTool.contains)
|
||||
}
|
||||
|
||||
private var toolbarSearchScope: ToolbarSearchScope? {
|
||||
if workspaceMode == .media { return .media }
|
||||
if selectedTool == nil, model.section == .files { return .files }
|
||||
return nil
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func toolbarSearchControl(_ scope: ToolbarSearchScope) -> some View {
|
||||
if isToolbarSearchExpanded {
|
||||
HStack(spacing: 6) {
|
||||
expandedToolbarSearchField(scope)
|
||||
Button { collapseToolbarSearch(clearQuery: true) } label: { Image(systemName: "xmark") }
|
||||
.buttonStyle(.plain)
|
||||
.help("关闭搜索")
|
||||
.accessibilityLabel("关闭搜索")
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.frame(width: 286, height: 30)
|
||||
.background(Color.primary.opacity(0.07), in: RoundedRectangle(cornerRadius: 7, style: .continuous))
|
||||
} else {
|
||||
Button { expandToolbarSearch(scope) } label: { Image(systemName: "magnifyingglass") }
|
||||
.help(scope == .files ? "搜索全盘文件和文件夹" : "搜索影视")
|
||||
.accessibilityLabel(scope == .files ? "搜索全盘文件和文件夹" : "搜索影视")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func expandedToolbarSearchField(_ scope: ToolbarSearchScope) -> some View {
|
||||
switch scope {
|
||||
case .files:
|
||||
TextField("搜索全盘文件和文件夹", text: $globalSearchText)
|
||||
.textFieldStyle(.plain)
|
||||
.focused($toolbarSearchFocus, equals: .files)
|
||||
.onChange(of: globalSearchText) { _, query in model.searchFilesGlobally(query) }
|
||||
case .media:
|
||||
TextField("搜索影视", text: $model.mediaLibrarySearchText)
|
||||
.textFieldStyle(.plain)
|
||||
.focused($toolbarSearchFocus, equals: .media)
|
||||
}
|
||||
}
|
||||
|
||||
private func expandToolbarSearch(_ scope: ToolbarSearchScope) {
|
||||
withAnimation(.easeInOut(duration: 0.18)) { isToolbarSearchExpanded = true }
|
||||
DispatchQueue.main.async { toolbarSearchFocus = scope }
|
||||
}
|
||||
|
||||
private func collapseToolbarSearch(clearQuery: Bool) {
|
||||
toolbarSearchFocus = nil
|
||||
withAnimation(.easeInOut(duration: 0.18)) { isToolbarSearchExpanded = false }
|
||||
guard clearQuery else { return }
|
||||
switch toolbarSearchScope {
|
||||
case .files:
|
||||
globalSearchText = ""
|
||||
model.clearGlobalFileSearch()
|
||||
case .media:
|
||||
model.mediaLibrarySearchText = ""
|
||||
case nil:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppTitleToolbar: View {
|
||||
@ObservedObject var model: AppModel
|
||||
let selectedTool: WorkspaceTool?
|
||||
@Binding var searchText: String
|
||||
|
||||
private var supportsSearch: Bool {
|
||||
selectedTool == nil && [.files, .photos, .videos, .audio, .documents].contains(model.section)
|
||||
}
|
||||
private struct WorkspaceModeSwitcher: View {
|
||||
@Binding var selection: WorkspaceMode
|
||||
let isCompact: Bool
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Label(selectedTool?.rawValue ?? model.section.rawValue, systemImage: selectedTool?.icon ?? model.section.icon)
|
||||
.font(.headline.weight(.semibold))
|
||||
if supportsSearch {
|
||||
TextField("搜索当前列表", text: $searchText)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 260)
|
||||
HStack(spacing: 3) {
|
||||
ForEach(WorkspaceMode.allCases) { mode in
|
||||
modeButton(mode)
|
||||
}
|
||||
}
|
||||
.padding(3)
|
||||
.background(Color.primary.opacity(colorScheme == .dark ? 0.09 : 0.06), in: RoundedRectangle(cornerRadius: 9, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||
.stroke(Color.primary.opacity(colorScheme == .dark ? 0.18 : 0.11), lineWidth: 1)
|
||||
}
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityLabel("工作模式")
|
||||
}
|
||||
|
||||
private func modeButton(_ mode: WorkspaceMode) -> some View {
|
||||
let isSelected = selection == mode
|
||||
let selectedBackground = isSelected ? Color.primary.opacity(colorScheme == .dark ? 0.16 : 0.09) : Color.clear
|
||||
return Button {
|
||||
withAnimation(.easeInOut(duration: 0.18)) { selection = mode }
|
||||
} label: {
|
||||
Group {
|
||||
if isCompact {
|
||||
Label(mode.rawValue, systemImage: mode.icon).labelStyle(.iconOnly)
|
||||
} else {
|
||||
Label(mode.rawValue, systemImage: mode.icon).labelStyle(.titleAndIcon)
|
||||
}
|
||||
}
|
||||
.font(.callout.weight(isSelected ? .semibold : .medium))
|
||||
.foregroundStyle(isSelected ? Color.primary : Color.secondary)
|
||||
.frame(minWidth: isCompact ? 34 : 102, minHeight: 30)
|
||||
.padding(.horizontal, isCompact ? 2 : 10)
|
||||
.background(selectedBackground, in: RoundedRectangle(cornerRadius: 7, style: .continuous))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("切换到\(mode.rawValue)")
|
||||
.compactHoverHint(mode.rawValue, enabled: isCompact)
|
||||
.accessibilityLabel("切换到\(mode.rawValue)")
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,19 @@ import SwiftUI
|
||||
|
||||
@main
|
||||
struct guangya_macApp: App {
|
||||
init() {
|
||||
#if DEBUG
|
||||
// The bundle is copied into the Debug app by the "Copy Injection Bundle" build phase.
|
||||
let bundlePath = Bundle.main.path(forResource: "macOSInjection", ofType: "bundle")
|
||||
Bundle(path: bundlePath ?? "")?.load()
|
||||
#endif
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
}
|
||||
.windowToolbarStyle(.unified(showsTitle: false))
|
||||
.handlesExternalEvents(matching: [])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user