0df8013d3e
- 移除内置 AVPlayer 播放器,视频文件统一使用外部播放器 - 播放器选择器优化为 Grid 布局,显示真实应用图标 - 右键菜单合并为"播放"菜单,直接列出所有播放器 - 详情页剧集列表增加右键菜单 - 媒体库所有上下文菜单统一播放入口 - 修复启动时自动清理已删除文件的秒传记录 - 添加 prepareAndPerformFastTransfer 静态方法
520 lines
17 KiB
Swift
520 lines
17 KiB
Swift
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)
|
|
}
|
|
}
|