Files
guangya_client/guangya_mac/DualPanel.swift
T
ngfchl 0df8013d3e feat: 移除内置播放器,统一外部播放体验
- 移除内置 AVPlayer 播放器,视频文件统一使用外部播放器
- 播放器选择器优化为 Grid 布局,显示真实应用图标
- 右键菜单合并为"播放"菜单,直接列出所有播放器
- 详情页剧集列表增加右键菜单
- 媒体库所有上下文菜单统一播放入口
- 修复启动时自动清理已删除文件的秒传记录
- 添加 prepareAndPerformFastTransfer 静态方法
2026-07-18 00:50:21 +08:00

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