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

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 : [])
}
}