feat: 移除内置播放器,统一外部播放体验
- 移除内置 AVPlayer 播放器,视频文件统一使用外部播放器 - 播放器选择器优化为 Grid 布局,显示真实应用图标 - 右键菜单合并为"播放"菜单,直接列出所有播放器 - 详情页剧集列表增加右键菜单 - 媒体库所有上下文菜单统一播放入口 - 修复启动时自动清理已删除文件的秒传记录 - 添加 prepareAndPerformFastTransfer 静态方法
This commit is contained in:
@@ -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 : [])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user