feat: 移除内置播放器,统一外部播放体验

- 移除内置 AVPlayer 播放器,视频文件统一使用外部播放器
- 播放器选择器优化为 Grid 布局,显示真实应用图标
- 右键菜单合并为"播放"菜单,直接列出所有播放器
- 详情页剧集列表增加右键菜单
- 媒体库所有上下文菜单统一播放入口
- 修复启动时自动清理已删除文件的秒传记录
- 添加 prepareAndPerformFastTransfer 静态方法
This commit is contained in:
ngfchl
2026-07-18 00:50:21 +08:00
parent d56af3c4e1
commit 0df8013d3e
10 changed files with 7121 additions and 600 deletions
+18 -8
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+519
View File
@@ -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)
}
}
+87 -50
View File
@@ -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()
}
}
File diff suppressed because it is too large Load Diff
+173 -11
View File
@@ -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
View File
@@ -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?
+289 -68
View File
@@ -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 : [])
}
}
+9
View File
@@ -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: [])
}
}