0df8013d3e
- 移除内置 AVPlayer 播放器,视频文件统一使用外部播放器 - 播放器选择器优化为 Grid 布局,显示真实应用图标 - 右键菜单合并为"播放"菜单,直接列出所有播放器 - 详情页剧集列表增加右键菜单 - 媒体库所有上下文菜单统一播放入口 - 修复启动时自动清理已删除文件的秒传记录 - 添加 prepareAndPerformFastTransfer 静态方法
392 lines
16 KiB
Swift
392 lines
16 KiB
Swift
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
enum WorkspaceTool: String, CaseIterable, Identifiable, Hashable {
|
|
case media = "影视库"
|
|
case scan = "文件扫描与清理"
|
|
case rename = "批量重命名"
|
|
case fastTransfer = "秒传工具"
|
|
case tmdb = "TMDB 整理"
|
|
case settings = "工作区设置"
|
|
|
|
var id: String { rawValue }
|
|
var icon: String {
|
|
switch self {
|
|
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"
|
|
}
|
|
}
|
|
var subtitle: String {
|
|
switch self {
|
|
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> = []
|
|
@State private var renameFile: CloudFile?
|
|
@State private var isCreatingFolder = false
|
|
@State private var isImporting = false
|
|
@State private var isShowingSettings = false
|
|
@State private var isShowingDuplicates = false
|
|
@State private var isInspectorPresented = false
|
|
@State private var selectedTool: WorkspaceTool?
|
|
@State private var folderName = ""
|
|
@State private var columnVisibility: NavigationSplitViewVisibility = .all
|
|
@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? {
|
|
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 {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.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: 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
|
|
isCreatingFolder = false
|
|
folderName = ""
|
|
Task { await model.createFolder(name: name) }
|
|
} onCancel: { isCreatingFolder = false; folderName = "" }
|
|
}
|
|
.sheet(isPresented: $isShowingSettings) { WorkspaceSettings(model: model) }
|
|
.sheet(isPresented: $isShowingDuplicates) { DuplicateResults(model: model) }
|
|
.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) } }
|
|
case .failure(let error): model.errorMessage = error.localizedDescription
|
|
}
|
|
}
|
|
.alert("提示", isPresented: Binding(get: { !model.lastActionMessage.isEmpty }, set: { if !$0 { model.lastActionMessage = "" } })) {
|
|
Button("确定", role: .cancel) { model.lastActionMessage = "" }
|
|
} message: { Text(model.lastActionMessage) }
|
|
.task {
|
|
if model.files.isEmpty && !model.isLoadingFiles { await model.refresh() }
|
|
}
|
|
.onChange(of: selectedTool) { _, tool in
|
|
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
|
|
selectedFileIDs.formIntersection(Set(ids))
|
|
if selectedFileIDs.isEmpty { isInspectorPresented = false }
|
|
}
|
|
.onChange(of: model.folderPath) { _, _ in
|
|
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 WorkspaceModeSwitcher: View {
|
|
@Binding var selection: WorkspaceMode
|
|
let isCompact: Bool
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
var body: some View {
|
|
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 : [])
|
|
}
|
|
}
|