diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml
new file mode 100644
index 00000000..c7ffc6ba
--- /dev/null
+++ b/.codex/environments/environment.toml
@@ -0,0 +1,11 @@
+# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
+version = 1
+name = "FlowVision"
+
+[setup]
+script = ""
+
+[[actions]]
+name = "Run"
+icon = "run"
+command = "./script/build_and_run.sh"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..19ee614c
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,139 @@
+name: Build and Release
+
+on:
+ workflow_dispatch:
+ inputs:
+ version:
+ description: "Release version, for example v1.7.2"
+ required: true
+ type: string
+ draft:
+ description: "Create release as draft"
+ required: false
+ default: false
+ type: boolean
+ prerelease:
+ description: "Mark release as prerelease"
+ required: false
+ default: false
+ type: boolean
+ push:
+ tags:
+ - "v*"
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ name: Build macOS DMG and publish GitHub Release
+ runs-on: macos-latest
+
+ steps:
+ - name: Checkout FlowVision
+ uses: actions/checkout@v4
+ with:
+ path: FlowVision
+
+ - name: Checkout Settings dependency
+ uses: actions/checkout@v4
+ with:
+ repository: sindresorhus/Settings
+ path: Settings
+
+ - name: Checkout BTree dependency
+ uses: actions/checkout@v4
+ with:
+ repository: attaswift/BTree
+ path: BTree
+
+ - name: Download FFmpegKit XCFrameworks
+ shell: bash
+ env:
+ FFMPEG_KIT_URL: https://github.com/netdcy/ffmpeg-kit/releases/download/v6.0/ffmpeg-kit-full-gpl-6.0-macos-xcframework.zip
+ run: |
+ set -euo pipefail
+ mkdir -p ffmpeg-kit-build/bundle-apple-xcframework-macos
+ curl --fail --location --retry 3 --output ffmpeg-kit-build/ffmpeg-kit-full-gpl-6.0-macos-xcframework.zip "$FFMPEG_KIT_URL"
+ unzip -q ffmpeg-kit-build/ffmpeg-kit-full-gpl-6.0-macos-xcframework.zip -d ffmpeg-kit-build/bundle-apple-xcframework-macos
+
+ - name: Resolve release version
+ id: version
+ shell: bash
+ run: |
+ set -euo pipefail
+ if [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then
+ VERSION="${GITHUB_REF_NAME}"
+ else
+ VERSION="${{ inputs.version }}"
+ fi
+ VERSION="${VERSION#refs/tags/}"
+ if [[ -z "$VERSION" ]]; then
+ echo "Release version is required." >&2
+ exit 1
+ fi
+ if [[ "$VERSION" != v* ]]; then
+ VERSION="v$VERSION"
+ fi
+ SHORT_VERSION="${VERSION#v}"
+ echo "version=$VERSION" >> "$GITHUB_OUTPUT"
+ echo "short_version=$SHORT_VERSION" >> "$GITHUB_OUTPUT"
+ echo "dmg_name=FlowVision-${VERSION}-macOS" >> "$GITHUB_OUTPUT"
+
+ - name: Build DMG
+ working-directory: FlowVision
+ env:
+ ENABLE_CODESIGN: "0"
+ CONFIGURATION: Release
+ APP_NAME: FlowVision
+ DMG_NAME: ${{ steps.version.outputs.dmg_name }}
+ XCODEBUILD_EXTRA_ARGS: "MARKETING_VERSION=${{ steps.version.outputs.short_version }} CURRENT_PROJECT_VERSION=${{ github.run_number }} CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO CODE_SIGN_IDENTITY="
+ run: ./build_dmg.sh
+
+ - name: Create ZIP
+ working-directory: FlowVision
+ shell: bash
+ run: |
+ set -euo pipefail
+ APP_PATH="$(find build/DerivedData/Build/Products/Release -maxdepth 1 -type d -name '*.app' | head -n 1)"
+ if [[ -z "${APP_PATH:-}" ]]; then
+ echo "App bundle not found." >&2
+ exit 1
+ fi
+ mkdir -p dist
+ ditto -c -k --sequesterRsrc --keepParent "$APP_PATH" "dist/${{ steps.version.outputs.dmg_name }}.zip"
+ cp "dist/${{ steps.version.outputs.dmg_name }}.zip" "dist/FlowVision-macOS.zip"
+
+ - name: Upload workflow artifacts
+ uses: actions/upload-artifact@v4
+ with:
+ name: FlowVision-${{ steps.version.outputs.version }}-macOS
+ path: |
+ FlowVision/dist/*.dmg
+ FlowVision/dist/*.zip
+
+ - name: Publish GitHub Release
+ working-directory: FlowVision
+ env:
+ GH_TOKEN: ${{ github.token }}
+ VERSION: ${{ steps.version.outputs.version }}
+ DRAFT: ${{ inputs.draft || false }}
+ PRERELEASE: ${{ inputs.prerelease || false }}
+ shell: bash
+ run: |
+ set -euo pipefail
+
+ FLAGS=""
+ if [[ "$DRAFT" == "true" ]]; then
+ FLAGS="$FLAGS --draft"
+ fi
+ if [[ "$PRERELEASE" == "true" ]]; then
+ FLAGS="$FLAGS --prerelease"
+ fi
+
+ if gh release view "$VERSION" >/dev/null 2>&1; then
+ gh release upload "$VERSION" dist/*.dmg dist/*.zip --clobber
+ else
+ # shellcheck disable=SC2086
+ gh release create "$VERSION" dist/*.dmg dist/*.zip --title "FlowVision $VERSION" --generate-notes $FLAGS
+ fi
diff --git a/.gitignore b/.gitignore
index cdd0b098..2b28f42d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,6 @@
xcuserdata/
-LocalDev.xcconfig
\ No newline at end of file
+LocalDev.xcconfig
+.DS_Store
+.mindfs/
+dist/
+build/
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..ff6fde1e
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,84 @@
+# FlowVision Agent Guide
+
+本文件是仓库的低 token 导航入口。先读本文件,再按任务只打开
+[`public/doc/ARCHITECTURE.md`](public/doc/ARCHITECTURE.md) 中对应的小节和代码锚点;不要默认通读大型 Swift 文件。
+
+## 仓库与构建
+
+- 工程:`FlowVision.xcodeproj`;Scheme:`FlowVision`;目标平台:macOS。
+- 源码:`FlowVision/Sources/`;详细架构:`public/doc/ARCHITECTURE.md`。
+- 本地依赖通常位于相邻目录(如 `../Settings`、`../BTree`),另有 Xcode Package 依赖。
+- `build/`、`dist/` 是生成物,不属于源码,不应提交。
+- 统一构建/运行入口:`./script/build_and_run.sh`;可用 `--verify`、`--debug`、`--logs`、`--telemetry`。
+- 无签名 Release 验证:
+
+```bash
+xcodebuild -quiet -project FlowVision.xcodeproj -scheme FlowVision \
+ -configuration Release -destination 'platform=macOS' \
+ -derivedDataPath build/DerivedData \
+ CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build
+```
+
+## 快速定位
+
+优先使用 `rg -n '函数名|状态名' FlowVision/Sources`,按下表读取最少文件。
+
+| 任务 | 首选文件 / 锚点 |
+|---|---|
+| 重命名、复制、移动、删除 | `ViewControllerExtension/FileOperation.swift` |
+| 批量重命名入口与预览 | `handleBatchRenameSelectedItems`、`BatchRenamePreviewDataSource` |
+| 当前目录快捷重命名 | `handleQuickRenameInCurrentFolder` |
+| 重命名执行、回滚、Undo | `executeFileRenameMappings`、`executeFileRenameMappingsAsync` |
+| 重命名后无闪烁更新 | `applyRenameMappingsInPlace` |
+| 图片/视频复制到配置目录 | `handleCopyToConfiguredFolder` |
+| 缩略图右键菜单 | `Views/CustomCollectionViewItem.swift` |
+| 左侧目录树右键菜单 | `Views/CustomOutlineView.swift` |
+| 快捷键分发 | `ViewControllerExtension/KeyShortcut.swift`、`WindowController.swift` |
+| 文件扫描、刷新、刷新后定位 | `ViewControllerExtension/FileSystem.swift` / `selectItemsNewChanged` |
+| 跨操作状态 | `ViewController.swift` / `PublicVar` |
+| 排序键与文件模型 | `Common/DataModel.swift` / `SortKeyFile`、`FileModel` |
+| 视频播放连续性 | `Views/LargeImageView.swift`、`Views/CustomCollectionViewItem.swift` |
+| 邻近媒体 / SMB 预热 | `Common/VideoProcess.swift` / `MediaPreheatManager`、`ViewControllerExtension/LargeImage.swift` / `preloadLargeImage` |
+| 进度 UI | `Views/CoreAreaView.swift`、`ViewControllerExtension/ProgressBar.swift` |
+| Enhanced Index / Finder 元数据 | `Common/FinderTag.swift` |
+| FFmpeg 调用 | `Common/FFmpegKit.swift`;剪切规则见架构文档 |
+
+## 文件操作不变量
+
+1. 大批量磁盘 I/O、冲突检查、索引更新放后台队列;AppKit、集合视图、窗口标题、Undo 注册只在主线程。
+2. 文件操作期间正确维护 `publicVar.isInFileOperation`,避免文件系统监听器插入竞争刷新;所有成功、失败和提前返回路径都必须复位。
+3. 重命名使用“源文件 → 临时名 → 最终名”的两阶段移动。执行前检查重复目标和外部占用;部分失败必须回滚。
+4. `BTree.Map` 中的 `SortKeyFile` 是排序键,不能原地修改路径;应复制键并重建 Map,同时复用现有 `FileModel`。
+5. 纯重命名优先调用 `applyRenameMappingsInPlace`:只更新路径、名称、排序位置和标题,不重新配置缩略图/播放器,不调用 `reloadData()` 或 `scheduledRefresh()`。
+6. 原位更新条件不满足时才允许完整刷新;刷新前保存 `collectionScrollRestoreAfterRefresh`,最终在 `selectItemsNewChanged` 恢复可见位置。
+7. 重命名不得重启或停止视频。同步维护 `currentPlayingURL`、`restorePlayURL`、大图路径和窗口标题,保留播放进度。
+8. `filesForLocateAfterChange` 用于复制、创建、移动后的新目标定位;快捷重命名传空定位目标,不能把视图滚到第一个改名文件。
+9. 图片目录 1 和视频目录 2 的复制共用 `handleCopyToConfiguredFolder`。复制在后台执行,自动避让重名,完成后回主线程刷新/定位。
+10. 不要通过 Finder 剪贴板模拟应用内复制;直接使用 `FileManager`,错误和进度要可见。
+
+## 邻近媒体缓存不变量
+
+1. 大图浏览以当前媒体为中心维护前后各 5 个媒体的预热窗口;数量按图片/视频媒体计算,不按目录中的所有文件计算。
+2. 图片继续写入 `LargeImageProcessor` 解码缓存;图片预热最多并发 2,尺寸与元数据读取也不能阻塞主线程。
+3. 邻近视频只预读前 5 秒压缩视频样本到 macOS Unified Buffer Cache,并保留解析后的 `AVURLAsset`;不要生成需要和原片拼接的临时短视频。
+4. 视频预热串行执行。切换媒体时 `beginWindow` 必须取消旧队列并递增 generation,运行中的任务须检查 generation 后尽快退出。
+5. 当前视频不与邻项预热器重复读取:mpv/AVPlayer 自己维持 5 秒前向缓冲,开始播放后继续顺序预读。
+6. mpv 缓存必须有上限;当前约束为 5 秒目标缓冲、128 MiB 前向 demuxer 上限、32 MiB回看上限。禁止无界占用内存或磁盘。
+7. AVPlayer 路径优先复用 `mediaPreheatManager.preheatedAsset(for:)`,避免再次解析 SMB 媒体头。
+8. 放大/旋转等同一媒体内部刷新不能重建预热窗口;只有浏览位置变化时才调度。
+
+## FFmpeg 剪切约束
+
+- `-ss` 位于输入端且配合 `-c copy` 时只能从附近关键帧无损起切,不保证精确起点。
+- 不要默认 `-map 0:0 ...` 复制 iPhone MOV 的全部 data/metadata 流;这些流可能保留原始时间戳,造成黑屏、时长异常。
+- 只需音视频时优先 `-map 0:v:0 -map 0:a?`。要求帧精确时重编码视频,并显式重置时间戳;不要把 stream copy 描述成精确剪切。
+
+## 修改与验证
+
+- 保留工作区内用户已有改动;不要清理或覆盖无关 diff。
+- 编辑使用小范围补丁;禁止顺手重构与任务无关的大文件。
+- 最少验证:`git diff --check`,再按风险运行 Release 构建。
+- 重命名手工回归:多选预览、变量替换、冲突提示、Undo、排序变化、滚动位置、缩略图无闪烁、视频播放进度不丢。
+- 异步复制手工回归:图片目录 1、选中视频目录 2、当前视频目录 2、重名避让、失败提示、操作期间界面响应。
+- SMB 缓存手工回归:连续前后切换图片和视频、快速连翻后旧任务停止、首帧等待降低、播放 5 秒后持续加载、内存和网络读取保持有界。
+- 发布前检查 `git status --short`,只提交任务相关文件;push/tag 必须在用户明确要求后执行,tag不应该直接检查actions,需要用户确认才能执行。
diff --git a/FlowVision-Release-2026-04-19-v2.dmg b/FlowVision-Release-2026-04-19-v2.dmg
new file mode 100644
index 00000000..7c310f60
Binary files /dev/null and b/FlowVision-Release-2026-04-19-v2.dmg differ
diff --git a/FlowVision-Release-2026-04-19-v2.zip b/FlowVision-Release-2026-04-19-v2.zip
new file mode 100644
index 00000000..cdff809f
Binary files /dev/null and b/FlowVision-Release-2026-04-19-v2.zip differ
diff --git a/FlowVision-Release-2026-04-19.dmg b/FlowVision-Release-2026-04-19.dmg
new file mode 100644
index 00000000..763f16ea
Binary files /dev/null and b/FlowVision-Release-2026-04-19.dmg differ
diff --git a/FlowVision-Release-2026-04-19.zip b/FlowVision-Release-2026-04-19.zip
new file mode 100644
index 00000000..d0f32037
Binary files /dev/null and b/FlowVision-Release-2026-04-19.zip differ
diff --git a/FlowVision-Release-20260419.zip b/FlowVision-Release-20260419.zip
new file mode 100644
index 00000000..8f9e77fe
Binary files /dev/null and b/FlowVision-Release-20260419.zip differ
diff --git a/FlowVision.xcodeproj/project.pbxproj b/FlowVision.xcodeproj/project.pbxproj
index d8075ea2..9ec97570 100644
--- a/FlowVision.xcodeproj/project.pbxproj
+++ b/FlowVision.xcodeproj/project.pbxproj
@@ -7,6 +7,7 @@
objects = {
/* Begin PBXBuildFile section */
+ A1B2C3D42F90000100ABCDEF /* UpdateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D32F90000100ABCDEF /* UpdateManager.swift */; };
F70368E82BF609F300155F36 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = F70368E72BF609F300155F36 /* Localizable.xcstrings */; };
F70F9FC12BAFE7B300C50CDD /* Common.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70F9FC02BAFE7B300C50CDD /* Common.swift */; };
F70F9FC32BAFE85D00C50CDD /* CustomCollectionViewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70F9FC22BAFE85D00C50CDD /* CustomCollectionViewManager.swift */; };
@@ -72,6 +73,7 @@
F7A075582BA1D717009C47A6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F7A075572BA1D717009C47A6 /* Assets.xcassets */; };
F7A0755B2BA1D717009C47A6 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F7A075592BA1D717009C47A6 /* Main.storyboard */; };
F7AA00012F9E000000AA0001 /* VideoPlayerControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7AA00002F9E000000AA0001 /* VideoPlayerControlsView.swift */; };
+ 40694BBD27B5D5464D319BAE /* MPVPlayerBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = B513CF896032F495D8769284 /* MPVPlayerBackend.swift */; };
F7AAA8BD2D9BD1F2007CE330 /* VideoProcess.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7AAA8BC2D9BD1ED007CE330 /* VideoProcess.swift */; };
F7AC30052D37E26B00F48AEF /* CustomPathControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7AC30042D37E25800F48AEF /* CustomPathControl.swift */; };
F7C2DEB82C4E6AE9003DF765 /* Settings in Frameworks */ = {isa = PBXBuildFile; productRef = F7C2DEB72C4E6AE9003DF765 /* Settings */; };
@@ -114,6 +116,7 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
+ A1B2C3D32F90000100ABCDEF /* UpdateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateManager.swift; sourceTree = ""; };
E00E13C3056A4910B242187C /* Base.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Base.xcconfig; sourceTree = ""; };
F70368E72BF609F300155F36 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; };
F70F9FC02BAFE7B300C50CDD /* Common.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Common.swift; sourceTree = ""; };
@@ -175,6 +178,7 @@
F7790ED52BA5FB6A00406D35 /* CustomCollectionViewItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCollectionViewItem.swift; sourceTree = ""; };
F7790ED62BA5FB6A00406D35 /* CustomCollectionViewItem.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = CustomCollectionViewItem.xib; sourceTree = ""; };
F78960F12BDCC26B00C2571B /* LargeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageView.swift; sourceTree = ""; };
+ B513CF896032F495D8769284 /* MPVPlayerBackend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MPVPlayerBackend.swift; sourceTree = ""; };
F78960F52BDFFE9200C2571B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
F78D1A692D432DCE00741908 /* mul */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; name = mul; path = mul.lproj/Main.xcstrings; sourceTree = ""; };
F78D1A872D4CF31D00741908 /* GeneralSettingsViewController.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = GeneralSettingsViewController.xib; path = Base.lproj/CustomSettingsViewController.xib; sourceTree = ""; };
@@ -216,6 +220,7 @@
F73865DC2C37BD7F00837FE4 /* Common */ = {
isa = PBXGroup;
children = (
+ A1B2C3D32F90000100ABCDEF /* UpdateManager.swift */,
F76F6A622F4FE58000D798DC /* TempVariable.swift */,
F73865D82C37BBCB00837FE4 /* GlobalVariable.swift */,
F70F9FC02BAFE7B300C50CDD /* Common.swift */,
@@ -251,6 +256,7 @@
F7FAE0002F6BA00000FAE000 /* FavoritesPopoverViewController.swift */,
F78960F12BDCC26B00C2571B /* LargeImageView.swift */,
F73865D42C37B9B800837FE4 /* Layout.swift */,
+ B513CF896032F495D8769284 /* MPVPlayerBackend.swift */,
F7AA00002F9E000000AA0001 /* VideoPlayerControlsView.swift */,
);
path = Views;
@@ -473,6 +479,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ A1B2C3D42F90000100ABCDEF /* UpdateManager.swift in Sources */,
F78960F22BDCC26B00C2571B /* LargeImageView.swift in Sources */,
F78FAB0D2C0EE94D00FE66CC /* CustomImageView.swift in Sources */,
F76E2EFE2C3B765C0031B0B4 /* Log.swift in Sources */,
@@ -485,6 +492,7 @@
F7790ED72BA5FB6A00406D35 /* CustomCollectionViewItem.swift in Sources */,
F7663C162F222AAC0028DF35 /* ImageEditingView.swift in Sources */,
F7AA00012F9E000000AA0001 /* VideoPlayerControlsView.swift in Sources */,
+ 40694BBD27B5D5464D319BAE /* MPVPlayerBackend.swift in Sources */,
F74D555C2F0E501200C9AB85 /* EventHandler.swift in Sources */,
F74D55522F0E46D200C9AB85 /* Search.swift in Sources */,
F7FED5F32F7D0B1100E35164 /* Tagging.swift in Sources */,
@@ -729,7 +737,8 @@
CODE_SIGN_ENTITLEMENTS = FlowVision/FlowVision.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
- CURRENT_PROJECT_VERSION = 20260422;
+ CURRENT_PROJECT_VERSION = 20260809;
+ DEVELOPMENT_TEAM = M6WQA48PJ2;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = FlowVision/Info.plist;
@@ -744,7 +753,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
- MARKETING_VERSION = 1.7.1;
+ MARKETING_VERSION = 1.7.6;
MERGED_BINARY_TYPE = automatic;
PRODUCT_BUNDLE_IDENTIFIER = netdcy.FlowVisionDbg;
PRODUCT_NAME = "$(TARGET_NAME)Dbg";
@@ -762,8 +771,8 @@
CODE_SIGN_ENTITLEMENTS = FlowVision/FlowVision.entitlements;
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
- CURRENT_PROJECT_VERSION = 20260422;
- DEVELOPMENT_TEAM = M9PR3WG2FN;
+ CURRENT_PROJECT_VERSION = 20260809;
+ DEVELOPMENT_TEAM = M6WQA48PJ2;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = FlowVision/Info.plist;
@@ -778,7 +787,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
- MARKETING_VERSION = 1.7.1;
+ MARKETING_VERSION = 1.7.6;
MERGED_BINARY_TYPE = automatic;
PRODUCT_BUNDLE_IDENTIFIER = netdcy.FlowVision;
PRODUCT_NAME = "$(TARGET_NAME)";
diff --git a/FlowVision/Resources/Base.lproj/Main.storyboard b/FlowVision/Resources/Base.lproj/Main.storyboard
index 4edffd50..0807d896 100644
--- a/FlowVision/Resources/Base.lproj/Main.storyboard
+++ b/FlowVision/Resources/Base.lproj/Main.storyboard
@@ -1,8 +1,8 @@
-
+
-
+
@@ -1201,11 +1201,11 @@ Gw
-
+
-
+
diff --git a/FlowVision/Resources/Localizable.xcstrings b/FlowVision/Resources/Localizable.xcstrings
index 5510adc3..6dea10df 100644
--- a/FlowVision/Resources/Localizable.xcstrings
+++ b/FlowVision/Resources/Localizable.xcstrings
@@ -1071,6 +1071,9 @@
}
}
},
+ "Add Current Folder to Favorites" : {
+ "comment" : "收藏当前目录"
+ },
"Add Separator" : {
"comment" : "添加分隔线",
"localizations" : {
@@ -1178,6 +1181,9 @@
}
}
},
+ "Add to Favorites" : {
+ "comment" : "添加到收藏"
+ },
"Added Date" : {
"comment" : "添加日期",
"localizations" : {
@@ -2141,6 +2147,9 @@
}
}
},
+ "Archive" : {
+ "comment" : "压缩包"
+ },
"Are you sure you want to move xxx to xxx?" : {
"comment" : "确定要移动 xxx 到 xxx?",
"localizations" : {
@@ -4388,6 +4397,9 @@
}
}
},
+ "Cache external folder thumbnails locally" : {
+ "comment" : "本地缓存外接卷文件夹缩略图"
+ },
"Cancel" : {
"comment" : "取消",
"localizations" : {
@@ -5352,7 +5364,7 @@
}
},
"Clear" : {
- "comment" : "清除",
+ "comment" : "清理\n清除",
"localizations" : {
"ar" : {
"stringUnit" : {
@@ -5886,6 +5898,32 @@
}
}
},
+ "Collected %d files" : {
+ "comment" : "已归集 %d 个文件"
+ },
+ "Collected %d files (%d failed)" : {
+ "comment" : "已归集 %d 个文件(%d 个失败)",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Collected %1$d files (%2$d failed)"
+ }
+ }
+ }
+ },
+ "Compressing... %d%%" : {
+ "comment" : "压缩中... %d%%"
+ },
+ "Compressing... 0%" : {
+ "comment" : "压缩中... 0%"
+ },
+ "Compression complete" : {
+ "comment" : "压缩完成"
+ },
+ "Compression failed." : {
+ "comment" : "压缩失败。"
+ },
"Continue" : {
"comment" : "继续",
"localizations" : {
@@ -6635,6 +6673,41 @@
}
}
},
+ "Crop area is too small" : {
+ "comment" : "裁剪区域太小"
+ },
+ "Crop complete" : {
+ "comment" : "裁剪完成"
+ },
+ "Crop complete, failed: %d" : {
+ "comment" : "裁剪完成,失败:%d"
+ },
+ "Crop Size" : {
+ "comment" : "裁剪尺寸"
+ },
+ "Crop Video Size" : {
+ "comment" : "裁剪视频尺寸"
+ },
+ "Crop Video Size..." : {
+ "comment" : "裁剪视频尺寸..."
+ },
+ "Cropping %@" : {
+ "comment" : "裁剪中 %@"
+ },
+ "Cropping %d/%d: %@" : {
+ "comment" : "裁剪中 %d/%d: %@",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Cropping %1$d/%2$d: %3$@"
+ }
+ }
+ }
+ },
+ "Cropping... %d%%" : {
+ "comment" : "裁剪中... %d%%"
+ },
"Current Filter" : {
"comment" : "当前过滤",
"localizations" : {
@@ -7491,6 +7564,12 @@
}
}
},
+ "Default compression password is empty." : {
+ "comment" : "默认压缩密码为空。"
+ },
+ "Default compression password is empty. Please set it in Settings." : {
+ "comment" : "默认压缩密码为空,请先在设置中配置。"
+ },
"Default Thumbnail Size" : {
"comment" : "默认缩略图大小",
"localizations" : {
@@ -8454,6 +8533,9 @@
}
}
},
+ "Drag to select video crop area" : {
+ "comment" : "拖动选择视频裁剪区域"
+ },
"Edit Mode" : {
"comment" : "编辑模式",
"localizations" : {
@@ -9310,6 +9392,9 @@
}
}
},
+ "Encrypt ZIP" : {
+ "comment" : "加密压缩 ZIP"
+ },
"Enlarge the Thumbnails" : {
"comment" : "放大缩略图",
"localizations" : {
@@ -9738,6 +9823,9 @@
}
}
},
+ "Enter the target crop width and height in pixels. The video will be center-cropped and the original file will be replaced." : {
+ "comment" : "输入目标裁剪宽高(像素)。视频将居中裁剪并替换原文件。"
+ },
"Eraser" : {
"comment" : "橡皮擦",
"localizations" : {
@@ -13376,6 +13464,36 @@
}
}
},
+ "Extraction failed." : {
+ "comment" : "解压失败。"
+ },
+ "Failed to capture current video frame." : {
+ "comment" : "抓取当前视频帧失败。"
+ },
+ "Failed to copy some files: %@" : {
+ "comment" : "部分文件复制失败:%@"
+ },
+ "Failed to create collection folder." : {
+ "comment" : "创建归集文件夹失败。"
+ },
+ "Failed to crop some videos: %@" : {
+ "comment" : "部分视频裁剪失败:%@"
+ },
+ "Failed to crop video." : {
+ "comment" : "视频裁剪失败。"
+ },
+ "Failed to encode captured frame." : {
+ "comment" : "编码截图失败。"
+ },
+ "Failed to execute zip." : {
+ "comment" : "执行压缩失败。"
+ },
+ "Failed to extract some archives: %@" : {
+ "comment" : "部分压缩包解压失败:%@"
+ },
+ "Failed to rotate some files: %@" : {
+ "comment" : "部分文件旋转失败:%@"
+ },
"Failed to set this app as the default for some file types:\n" : {
"comment" : "未能将此应用设置为某些文件类型的默认应用程序:\n",
"localizations" : {
@@ -13484,7 +13602,7 @@
}
},
"Favorites" : {
- "comment" : "收藏夹",
+ "comment" : "收藏\n收藏夹",
"localizations" : {
"ar" : {
"stringUnit" : {
@@ -15623,6 +15741,9 @@
}
}
},
+ "Folder Thumbnail Cache:" : {
+ "comment" : "文件夹缩略图缓存"
+ },
"Folders" : {
"comment" : "目录",
"localizations" : {
@@ -15837,6 +15958,9 @@
}
}
},
+ "Frame Saved" : {
+ "comment" : "视频帧已保存"
+ },
"gen-thumb-info" : {
"comment" : "对于高清缩略图的说明...",
"localizations" : {
@@ -17014,6 +17138,9 @@
}
}
},
+ "Height" : {
+ "comment" : "高度"
+ },
"Highlighter" : {
"comment" : "荧光笔",
"localizations" : {
@@ -17121,6 +17248,9 @@
}
}
},
+ "History" : {
+ "comment" : "历史"
+ },
"Image" : {
"comment" : "图像",
"localizations" : {
@@ -18619,6 +18749,9 @@
}
}
},
+ "Local cache: %@" : {
+ "comment" : "本地缓存大小"
+ },
"Location" : {
"comment" : "位置",
"localizations" : {
@@ -20117,6 +20250,74 @@
}
}
},
+ "Move complete" : {
+ "comment" : "移动完成",
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "移动完成"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "移動完成"
+ }
+ }
+ }
+ },
+ "Move failed for %d item(s)" : {
+ "comment" : "有 %d 个项目移动失败",
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d 个项目移动失败"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "%d 個項目移動失敗"
+ }
+ }
+ }
+ },
+ "Moved %d item(s), %d failed" : {
+ "comment" : "已移动 %d 个项目,%d 个失败",
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "已移动 %d 个项目,%d 个失败"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "已移動 %d 個項目,%d 個失敗"
+ }
+ }
+ }
+ },
+ "Moving %d item(s)…" : {
+ "comment" : "正在移动 %d 个项目…",
+ "localizations" : {
+ "zh-Hans" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "正在移动 %d 个项目…"
+ }
+ },
+ "zh-Hant" : {
+ "stringUnit" : {
+ "state" : "translated",
+ "value" : "正在移動 %d 個項目…"
+ }
+ }
+ }
+ },
"Move Items" : {
"comment" : "移动项目",
"localizations" : {
@@ -21722,6 +21923,15 @@
}
}
},
+ "No files found in subfolders." : {
+ "comment" : "子文件夹中未找到可归集文件。"
+ },
+ "No files to rename in current folder." : {
+ "comment" : "当前目录没有可重命名的文件。"
+ },
+ "No files were collected from subfolders." : {
+ "comment" : "未能从子文件夹中归集到文件。"
+ },
"No Rating" : {
"comment" : "无评级",
"localizations" : {
@@ -24504,6 +24714,9 @@
}
}
},
+ "Password cannot be empty." : {
+ "comment" : "密码不能为空。"
+ },
"Paste" : {
"comment" : "粘贴",
"localizations" : {
@@ -24825,6 +25038,9 @@
}
}
},
+ "Photo Folder 1 does not exist or is not a folder." : {
+ "comment" : "图片文件夹1不存在或不是文件夹。"
+ },
"Pin Window" : {
"comment" : "置顶",
"localizations" : {
@@ -25146,6 +25362,15 @@
}
}
},
+ "Please enter a valid video crop size." : {
+ "comment" : "请输入有效的视频裁剪尺寸。"
+ },
+ "Please input ZIP password:" : {
+ "comment" : "请输入 ZIP 密码:"
+ },
+ "Please open a video first." : {
+ "comment" : "请先打开一个视频。"
+ },
"Please select a specific tag" : {
"comment" : "请选择具体的标签",
"localizations" : {
@@ -25253,6 +25478,27 @@
}
}
},
+ "Please select archive files first." : {
+ "comment" : "请先选择压缩包文件。"
+ },
+ "Please select at least one image or video first." : {
+ "comment" : "请先选择至少一个图片或视频。"
+ },
+ "Please select at least one video first." : {
+ "comment" : "请先选择至少一个视频。"
+ },
+ "Please select folders only." : {
+ "comment" : "请仅选择文件夹。"
+ },
+ "Please select items from the same folder." : {
+ "comment" : "请在同一目录下选择要压缩的项目。"
+ },
+ "Please set Photo Folder 1 in Settings first." : {
+ "comment" : "请先在设置中配置图片文件夹1。"
+ },
+ "Please set Video Folder 2 in Settings first." : {
+ "comment" : "请先在设置中配置视频文件夹2。"
+ },
"Portable Browsing Mode" : {
"comment" : "便携浏览模式",
"localizations" : {
@@ -26109,6 +26355,9 @@
}
}
},
+ "Profile Switching:" : {
+ "comment" : "配置切换:"
+ },
"qrcode-recog-fail" : {
"comment" : "未能识别到二维码",
"localizations" : {
@@ -26323,6 +26572,9 @@
}
}
},
+ "Quick Rename" : {
+ "comment" : "快速重命名"
+ },
"Quick Search" : {
"comment" : "快速搜索",
"localizations" : {
@@ -28784,6 +29036,9 @@
}
}
},
+ "Remove from Favorites" : {
+ "comment" : "取消收藏"
+ },
"Rename" : {
"comment" : "重命名",
"localizations" : {
@@ -29319,6 +29574,26 @@
}
}
},
+ "Restore complete" : {
+ "comment" : "还原完成"
+ },
+ "Restore Video Rotation" : {
+ "comment" : "还原视频旋转"
+ },
+ "Restoring %d/%d: %@" : {
+ "comment" : "还原中 %d/%d: %@",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Restoring %1$d/%2$d: %3$@"
+ }
+ }
+ }
+ },
+ "Restoring... %d%%" : {
+ "comment" : "还原中... %d%%"
+ },
"Reverse Filter" : {
"comment" : "反转筛选",
"localizations" : {
@@ -29533,6 +29808,9 @@
}
}
},
+ "Rotate" : {
+ "comment" : "旋转"
+ },
"Rotate %d°" : {
"comment" : "(视频)旋转%d°",
"localizations" : {
@@ -29640,6 +29918,9 @@
}
}
},
+ "Rotate 180°" : {
+ "comment" : "旋转180°"
+ },
"Rotate Clockwise" : {
"comment" : "顺时针旋转",
"localizations" : {
@@ -29747,6 +30028,9 @@
}
}
},
+ "Rotate Clockwise 90°" : {
+ "comment" : "顺时针旋转90°"
+ },
"Rotate Counterclockwise" : {
"comment" : "逆时针旋转",
"localizations" : {
@@ -29854,6 +30138,32 @@
}
}
},
+ "Rotate Counterclockwise 90°" : {
+ "comment" : "逆时针旋转90°"
+ },
+ "Rotate Selected Media" : {
+ "comment" : "旋转选中的媒体"
+ },
+ "Rotating %d/%d: %@" : {
+ "comment" : "旋转中 %d/%d: %@",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Rotating %1$d/%2$d: %3$@"
+ }
+ }
+ }
+ },
+ "Rotating... %d%%" : {
+ "comment" : "旋转中... %d%%"
+ },
+ "Rotation complete" : {
+ "comment" : "旋转完成"
+ },
+ "Rotation complete, failed: %d" : {
+ "comment" : "旋转完成,失败:%d"
+ },
"Save" : {
"comment" : "保存",
"localizations" : {
@@ -40768,6 +41078,9 @@
}
}
},
+ "Use the check button to crop, drag again to adjust" : {
+ "comment" : "点击对号裁剪,重新拖动可调整"
+ },
"Video" : {
"comment" : "视频",
"localizations" : {
@@ -40875,6 +41188,12 @@
}
}
},
+ "Video crop size must be at least 2 pixels." : {
+ "comment" : "视频裁剪尺寸至少需要 2 像素。"
+ },
+ "Video Folder 2 does not exist or is not a folder." : {
+ "comment" : "视频文件夹2不存在或不是文件夹。"
+ },
"video-dimensions" : {
"comment" : "视频尺寸",
"localizations" : {
@@ -43015,6 +43334,12 @@
}
}
},
+ "Virtual entries are not supported for this operation." : {
+ "comment" : "该操作不支持虚拟目录或压缩包内虚拟条目。"
+ },
+ "Virtual entries cannot be compressed." : {
+ "comment" : "虚拟目录或压缩包内虚拟条目不支持压缩。"
+ },
"Volume" : {
"comment" : "音量",
"localizations" : {
@@ -43229,6 +43554,9 @@
}
}
},
+ "Width" : {
+ "comment" : "宽度"
+ },
"Window Title" : {
"comment" : "窗口标题",
"localizations" : {
@@ -43763,7 +44091,52 @@
}
}
}
+ },
+ "使用默认密码加密压缩" : {
+ "comment" : "使用默认密码加密压缩"
+ },
+ "加密压缩..." : {
+ "comment" : "加密压缩..."
+ },
+ "压缩为 ZIP" : {
+ "comment" : "压缩为 ZIP"
+ },
+ "压缩并删除源文件" : {
+ "comment" : "压缩并删除源文件"
+ },
+ "快速压缩" : {
+ "comment" : "快速压缩"
+ },
+ "快速重命名" : {
+ "comment" : "quick rename undo"
+ },
+ "批量重命名" : {
+ "comment" : "batch rename undo"
+ },
+ "提取子文件夹文件并归集" : {
+ "comment" : "提取子文件夹文件并归集"
+ },
+ "无法完成重命名,目标已存在:%@" : {
+ "comment" : "rename undo conflict"
+ },
+ "显示压缩文件" : {
+ "comment" : "显示压缩文件"
+ },
+ "解压到当前目录" : {
+ "comment" : "解压到当前目录"
+ },
+ "解压并删除压缩包" : {
+ "comment" : "解压并删除压缩包"
+ },
+ "返回上一级目录" : {
+ "comment" : "返回上一级目录"
+ },
+ "重命名" : {
+ "comment" : "rename undo"
+ },
+ "重命名失败:%@" : {
+ "comment" : "rename failed"
}
},
"version" : "1.0"
-}
\ No newline at end of file
+}
diff --git a/FlowVision/Sources/AppDelegate.swift b/FlowVision/Sources/AppDelegate.swift
index ab821dab..d6d3ab32 100644
--- a/FlowVision/Sources/AppDelegate.swift
+++ b/FlowVision/Sources/AppDelegate.swift
@@ -132,6 +132,12 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa
if let thumbnailOfFolderUseStacking = UserDefaults.standard.value(forKey: "thumbnailOfFolderUseStacking") as? Bool {
globalVar.thumbnailOfFolderUseStacking = thumbnailOfFolderUseStacking
}
+ if let showFolderMediaCountBadge = UserDefaults.standard.value(forKey: "showFolderMediaCountBadge") as? Bool {
+ globalVar.showFolderMediaCountBadge = showFolderMediaCountBadge
+ }
+ if let cacheExternalFolderThumbnails = UserDefaults.standard.value(forKey: "cacheExternalFolderThumbnails") as? Bool {
+ globalVar.cacheExternalFolderThumbnails = cacheExternalFolderThumbnails
+ }
if let loopBrowsing = UserDefaults.standard.value(forKey: "loopBrowsing") as? Bool {
globalVar.loopBrowsing = loopBrowsing
}
@@ -193,6 +199,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa
if let useInternalPlayer = UserDefaults.standard.value(forKey: "useInternalPlayer") as? Bool {
globalVar.useInternalPlayer = useInternalPlayer
}
+ if let preferIINAForExternalVideoPlayer = UserDefaults.standard.value(forKey: "preferIINAForExternalVideoPlayer") as? Bool {
+ globalVar.preferIINAForExternalVideoPlayer = preferIINAForExternalVideoPlayer
+ }
if let isEnterKeyToOpen = UserDefaults.standard.value(forKey: "isEnterKeyToOpen") as? Bool {
globalVar.isEnterKeyToOpen = isEnterKeyToOpen
}
@@ -217,6 +226,36 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa
if let enhancedIndexEnabled = UserDefaults.standard.value(forKey: "enhancedIndexEnabled") as? Bool {
globalVar.enhancedIndexEnabled = enhancedIndexEnabled
}
+ if let photoFolder1Path = UserDefaults.standard.value(forKey: "photoFolder1Path") as? String {
+ globalVar.photoFolder1Path = photoFolder1Path
+ }
+ if let photoFolder1CopyShortcut = UserDefaults.standard.value(forKey: "photoFolder1CopyShortcut") as? String,
+ !photoFolder1CopyShortcut.isEmpty {
+ globalVar.photoFolder1CopyShortcut = photoFolder1CopyShortcut.uppercased()
+ }
+ if let photoFolder2Path = UserDefaults.standard.value(forKey: "photoFolder2Path") as? String {
+ globalVar.photoFolder2Path = photoFolder2Path
+ }
+ if let photoFolder2CopyShortcut = UserDefaults.standard.value(forKey: "photoFolder2CopyShortcut") as? String,
+ !photoFolder2CopyShortcut.isEmpty {
+ globalVar.photoFolder2CopyShortcut = photoFolder2CopyShortcut.uppercased()
+ }
+ if let quickRenameRule = UserDefaults.standard.value(forKey: "quickRenameRule") as? String,
+ !quickRenameRule.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ globalVar.quickRenameRule = quickRenameRule
+ }
+ if let videoShiftArrowSwitchFile = UserDefaults.standard.value(forKey: "videoShiftArrowSwitchFile") as? Bool {
+ globalVar.videoShiftArrowSwitchFile = videoShiftArrowSwitchFile
+ }
+ if let showArchiveFileType = UserDefaults.standard.value(forKey: "showArchiveFileType") as? Bool {
+ globalVar.showArchiveFileType = showArchiveFileType
+ }
+ if let compressionDefaultPassword = UserDefaults.standard.value(forKey: "compressionDefaultPassword") as? String {
+ globalVar.compressionDefaultPassword = compressionDefaultPassword
+ }
+ if let compressionUseDefaultPassword = UserDefaults.standard.value(forKey: "compressionUseDefaultPassword") as? Bool {
+ globalVar.compressionUseDefaultPassword = compressionUseDefaultPassword
+ }
if let collectionViewItemShowTooltip = UserDefaults.standard.value(forKey: "collectionViewItemShowTooltip") as? Bool {
globalVar.collectionViewItemShowTooltip = collectionViewItemShowTooltip
}
@@ -252,11 +291,17 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa
log("Start applicationDidFinishLaunching")
// Start applicationDidFinishLaunching
+
+ installUpdateMenuItem()
if windowControllers.count == 0 {
_ = createNewWindow()
}
+ DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
+ FlowVisionUpdateManager.shared.checkForUpdates(manual: false)
+ }
+
// DispatchQueue.global(qos: .userInitiated).async {
// Thread.sleep(forTimeInterval: 8)
// FFmpegKitWrapper.shared.loadFFmpegKitIfNeeded()
@@ -266,6 +311,29 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa
// End applicationDidFinishLaunching
}
+ private func installUpdateMenuItem() {
+ guard let applicationMenu = NSApp.mainMenu?.items.first?.submenu,
+ !applicationMenu.items.contains(where: { $0.action == #selector(checkForUpdates(_:)) }) else {
+ return
+ }
+ let title: String
+ let language = Locale.preferredLanguages.first?.lowercased() ?? "en"
+ if language.hasPrefix("zh-hans") || language.hasPrefix("zh-cn") {
+ title = "检查更新…"
+ } else if language.hasPrefix("zh-hant") || language.hasPrefix("zh-tw") || language.hasPrefix("zh-hk") {
+ title = "檢查更新…"
+ } else {
+ title = "Check for Updates…"
+ }
+ let item = NSMenuItem(title: title, action: #selector(checkForUpdates(_:)), keyEquivalent: "")
+ item.target = self
+ applicationMenu.insertItem(item, at: min(1, applicationMenu.items.count))
+ }
+
+ @objc private func checkForUpdates(_ sender: Any?) {
+ FlowVisionUpdateManager.shared.checkForUpdates(manual: true)
+ }
+
func applicationWillTerminate(_ aNotification: Notification) {
EnhancedIndex.flushPendingSave()
log("App EXIT")
@@ -533,6 +601,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa
.replacingOccurrences(of: "file://", with: "")
.removingPercentEncoding!
.replacingOccurrences(of: "/VirtualFinderTagsFolder", with: NSLocalizedString("Finder Tags", comment: "Finder标签"))
+ .replacingOccurrences(of: "/VirtualFavoritesFolder", with: NSLocalizedString("Favorites", comment: "收藏"))
+ .replacingOccurrences(of: "/VirtualHistoryFolder", with: NSLocalizedString("History", comment: "历史"))
+ .replacingOccurrences(of: "/VirtualArchiveFolder", with: NSLocalizedString("Archive", comment: "压缩包"))
let folderMenuItem = NSMenuItem(
title: displayTitle,
action: #selector(pathClick(_:)),
@@ -622,6 +693,9 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa
.replacingOccurrences(of: "file://", with: "")
.removingPercentEncoding!
.replacingOccurrences(of: "/VirtualFinderTagsFolder", with: NSLocalizedString("Finder Tags", comment: "Finder标签"))
+ .replacingOccurrences(of: "/VirtualFavoritesFolder", with: NSLocalizedString("Favorites", comment: "收藏"))
+ .replacingOccurrences(of: "/VirtualHistoryFolder", with: NSLocalizedString("History", comment: "历史"))
+ .replacingOccurrences(of: "/VirtualArchiveFolder", with: NSLocalizedString("Archive", comment: "压缩包"))
let menuItem = NSMenuItem(title: historyDisplayTitle, action: #selector(pathClick(_:)), keyEquivalent: "")
menuItem.representedObject = item
menuItem.target = self
@@ -791,7 +865,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa
// If focus is on OutlineView
if mainViewController.publicVar.isOutlineViewFirstResponder{
if let url = mainViewController.outlineView.getFirstSelectedUrl() {
- if url.absoluteString.hasPrefix("file:///VirtualFinderTagsFolder") {
+ if isVirtualFolderPath(url.absoluteString) {
return false
}
} else {
@@ -818,7 +892,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa
if mainViewController.publicVar.isInLargeView {
return false
}
- if mainViewController.fileDB.curFolder.hasPrefix("file:///VirtualFinderTagsFolder") {
+ if isReadOnlyVirtualFolderPath(mainViewController.fileDB.curFolder) {
return false
}
let pasteboard = NSPasteboard.general
@@ -889,26 +963,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa
mainViewController.fileDB.lock()
let curFolder=mainViewController.fileDB.curFolder
mainViewController.fileDB.unlock()
- if !globalVar.myFavoritesArray.contains(curFolder) {
- globalVar.myFavoritesArray.append(curFolder)
- let defaults = UserDefaults.standard
- defaults.set(globalVar.myFavoritesArray, forKey: "globalVar.myFavoritesArray")
- }
+ _ = addFavoritePath(curFolder)
}
@objc func deleteFavorite(_ sender: NSMenuItem) {
guard let folderPath = sender.representedObject as? String else { return }
-
- // 在这里处理删除逻辑
- // Handle delete logic here
- if let index = globalVar.myFavoritesArray.firstIndex(of: folderPath) {
- globalVar.myFavoritesArray.remove(at: index)
- let defaults = UserDefaults.standard
- defaults.set(globalVar.myFavoritesArray, forKey: "globalVar.myFavoritesArray")
- }
-
- // 更新菜单以反映更改
- // Update menu to reflect changes
- // menuNeedsUpdate(favoritesMenu)
+ _ = removeFavoritePath(folderPath)
}
@objc func moveUpFavorite(_ sender: NSMenuItem) {
guard let index = sender.representedObject as? Int, index > 0 else { return }
@@ -1261,4 +1320,3 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate, NSMenuItemVa
getMainViewController()?.toggleSearchOverlay()
}
}
-
diff --git a/FlowVision/Sources/Common/Common.swift b/FlowVision/Sources/Common/Common.swift
index 70c10fb3..b3341d79 100644
--- a/FlowVision/Sources/Common/Common.swift
+++ b/FlowVision/Sources/Common/Common.swift
@@ -129,6 +129,292 @@ func getFileSchemeAbsPath(_ path: String) -> String {
return pathWithScheme
}
+let VIRTUAL_FINDER_TAGS_PREFIX = "file:///VirtualFinderTagsFolder"
+let VIRTUAL_FAVORITES_PREFIX = "file:///VirtualFavoritesFolder"
+let VIRTUAL_HISTORY_PREFIX = "file:///VirtualHistoryFolder"
+let VIRTUAL_ARCHIVE_PREFIX = "file:///VirtualArchiveFolder"
+
+@discardableResult
+func openVideoWithPreferredExternalPlayer(_ url: URL) -> Bool {
+ if globalVar.preferIINAForExternalVideoPlayer,
+ let iinaURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.colliderli.iina") {
+ NSWorkspace.shared.open([url], withApplicationAt: iinaURL, configuration: NSWorkspace.OpenConfiguration())
+ return true
+ }
+ NSWorkspace.shared.open(url)
+ return true
+}
+
+func isVirtualFolderPath(_ path: String) -> Bool {
+ return path.hasPrefix(VIRTUAL_FINDER_TAGS_PREFIX)
+ || path.hasPrefix(VIRTUAL_FAVORITES_PREFIX)
+ || path.hasPrefix(VIRTUAL_HISTORY_PREFIX)
+ || path.hasPrefix(VIRTUAL_ARCHIVE_PREFIX)
+}
+
+func isReadOnlyVirtualFolderPath(_ path: String) -> Bool {
+ return isVirtualFolderPath(path)
+}
+
+func isVirtualArchivePath(_ path: String) -> Bool {
+ return path.hasPrefix(VIRTUAL_ARCHIVE_PREFIX)
+}
+
+func isVirtualArchiveRootPath(_ path: String) -> Bool {
+ guard isVirtualArchivePath(path) else { return false }
+ let prefix = "\(VIRTUAL_ARCHIVE_PREFIX)/"
+ guard path.hasPrefix(prefix) else { return false }
+ let remain = String(path.dropFirst(prefix.count))
+ return !remain.isEmpty && !remain.contains("/")
+}
+
+func isVirtualArchiveEntryPath(_ path: String) -> Bool {
+ guard isVirtualArchivePath(path) else { return false }
+ let prefix = "\(VIRTUAL_ARCHIVE_PREFIX)/"
+ guard path.hasPrefix(prefix) else { return false }
+ let remain = String(path.dropFirst(prefix.count))
+ let comps = remain.split(separator: "/", omittingEmptySubsequences: true)
+ return comps.count >= 2
+}
+
+func parseVirtualArchivePath(_ path: String) -> (archiveURL: URL, entryPath: String?)? {
+ let prefix = "\(VIRTUAL_ARCHIVE_PREFIX)/"
+ guard path.hasPrefix(prefix) else { return nil }
+ let remain = String(path.dropFirst(prefix.count))
+ guard !remain.isEmpty else { return nil }
+ let comps = remain.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: true)
+ guard let encodedArchive = comps.first else {
+ return nil
+ }
+ let archiveAbsPath = String(encodedArchive).removingPercentEncoding ?? String(encodedArchive)
+ let archiveURL: URL?
+ if let parsed = URL(string: archiveAbsPath) {
+ archiveURL = parsed
+ } else if archiveAbsPath.hasPrefix("file://") {
+ let rawPath = String(archiveAbsPath.dropFirst("file://".count)).removingPercentEncoding
+ ?? String(archiveAbsPath.dropFirst("file://".count))
+ archiveURL = URL(fileURLWithPath: rawPath)
+ } else {
+ archiveURL = nil
+ }
+ guard let archiveURL else { return nil }
+ if comps.count == 1 {
+ return (archiveURL, nil)
+ }
+ let encodedEntryPath = String(comps[1])
+ let entryPath = encodedEntryPath.removingPercentEncoding ?? encodedEntryPath
+ return (archiveURL, entryPath)
+}
+
+private let archiveEntryDataCache = NSCache()
+
+private func bsdtarEscapedPathBytes(_ text: String) -> [UInt8] {
+ let chars = Array(text.utf8)
+ var out: [UInt8] = []
+ var i = 0
+ while i < chars.count {
+ let c = chars[i]
+ if c == 92, i + 1 < chars.count { // '\'
+ // Octal form: \ooo
+ if i + 3 < chars.count,
+ chars[i + 1] >= 48, chars[i + 1] <= 55,
+ chars[i + 2] >= 48, chars[i + 2] <= 55,
+ chars[i + 3] >= 48, chars[i + 3] <= 55 {
+ let value = Int(chars[i + 1] - 48) * 64
+ + Int(chars[i + 2] - 48) * 8
+ + Int(chars[i + 3] - 48)
+ out.append(UInt8(value))
+ i += 4
+ continue
+ }
+ // Common escapes
+ let n = chars[i + 1]
+ switch n {
+ case 92: out.append(92) // \\
+ case 110: out.append(10) // \n
+ case 114: out.append(13) // \r
+ case 116: out.append(9) // \t
+ default:
+ out.append(n)
+ }
+ i += 2
+ continue
+ }
+ out.append(c)
+ i += 1
+ }
+ return out
+}
+
+private let archiveEntryPathAliasLock = NSLock()
+private var archiveEntryPathAliasMap: [String: String] = [:]
+
+private func archiveEntryPathAliasKey(archiveURL: URL, entryPath: String) -> String {
+ "\(archiveURL.absoluteString)|\(entryPath)"
+}
+
+func registerArchiveEntryPathAlias(archiveURL: URL, displayPath: String, rawPath: String) {
+ guard displayPath != rawPath else { return }
+ archiveEntryPathAliasLock.lock()
+ archiveEntryPathAliasMap[archiveEntryPathAliasKey(archiveURL: archiveURL, entryPath: displayPath)] = rawPath
+ archiveEntryPathAliasLock.unlock()
+}
+
+private func rawArchiveEntryPathAlias(archiveURL: URL, entryPath: String) -> String? {
+ archiveEntryPathAliasLock.lock()
+ let rawPath = archiveEntryPathAliasMap[archiveEntryPathAliasKey(archiveURL: archiveURL, entryPath: entryPath)]
+ archiveEntryPathAliasLock.unlock()
+ return rawPath
+}
+
+private func stringEncoding(ianaName: String) -> String.Encoding? {
+ let cfEncoding = CFStringConvertIANACharSetNameToEncoding(ianaName as CFString)
+ guard cfEncoding != kCFStringEncodingInvalidId else { return nil }
+ let nsEncoding = CFStringConvertEncodingToNSStringEncoding(cfEncoding)
+ guard nsEncoding != UInt(kCFStringEncodingInvalidId) else { return nil }
+ return String.Encoding(rawValue: nsEncoding)
+}
+
+private let archiveFilenameEncodings: [String.Encoding] = {
+ var encodings: [String.Encoding] = [.utf8, .shiftJIS]
+ for name in ["windows-31j", "cp932", "x-mac-japanese", "euc-jp", "iso-2022-jp"] {
+ if let encoding = stringEncoding(ianaName: name), !encodings.contains(encoding) {
+ encodings.append(encoding)
+ }
+ }
+ return encodings
+}()
+
+func decodeBsdtarEscapedPath(_ text: String) -> String {
+ let bytes = bsdtarEscapedPathBytes(text)
+ let data = Data(bytes)
+ for encoding in archiveFilenameEncodings {
+ if let decoded = String(data: data, encoding: encoding) {
+ return decoded
+ }
+ }
+ return text
+}
+
+func encodeBsdtarEscapedPath(_ text: String, encoding: String.Encoding = .utf8) -> String {
+ guard let data = text.data(using: encoding) else { return text }
+ var result = ""
+ for byte in data {
+ if byte >= 0x80 || byte == 0x5C {
+ result += String(format: "\\%03o", byte)
+ } else {
+ result.append(Character(UnicodeScalar(byte)))
+ }
+ }
+ return result
+}
+
+func getArchiveEntryData(archiveURL: URL, entryPath: String) -> Data? {
+ let cacheKey = "\(archiveURL.absoluteString)|\(entryPath)" as NSString
+ if let cached = archiveEntryDataCache.object(forKey: cacheKey) {
+ return Data(referencing: cached)
+ }
+
+ // Try decoded path first, then bsdtar-escaped fallback for legacy zip name encoding output.
+ var candidatePaths: [String] = []
+ if let rawAlias = rawArchiveEntryPathAlias(archiveURL: archiveURL, entryPath: entryPath) {
+ candidatePaths.append(rawAlias)
+ }
+ let decoded = decodeBsdtarEscapedPath(entryPath)
+ if !candidatePaths.contains(decoded) {
+ candidatePaths.append(decoded)
+ }
+ if decoded != entryPath {
+ if !candidatePaths.contains(entryPath) {
+ candidatePaths.append(entryPath)
+ }
+ } else {
+ let escaped = encodeBsdtarEscapedPath(entryPath)
+ if escaped != entryPath {
+ candidatePaths.append(escaped)
+ }
+ for encoding in archiveFilenameEncodings {
+ let encoded = encodeBsdtarEscapedPath(entryPath, encoding: encoding)
+ if encoded != entryPath && !candidatePaths.contains(encoded) {
+ candidatePaths.append(encoded)
+ }
+ }
+ }
+
+ for candidate in candidatePaths {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/bsdtar")
+ process.arguments = ["-xOf", archiveURL.path, candidate]
+ let stdOut = Pipe()
+ let stdErr = Pipe()
+ process.standardOutput = stdOut
+ process.standardError = stdErr
+
+ do {
+ try process.run()
+ } catch {
+ log("Archive stream failed: \(error)", level: .error)
+ continue
+ }
+
+ // Read stdout first to avoid pipe deadlock on large entries.
+ let data = stdOut.fileHandleForReading.readDataToEndOfFile()
+ process.waitUntilExit()
+
+ if process.terminationStatus == 0 {
+ archiveEntryDataCache.setObject(data as NSData, forKey: cacheKey)
+ return data
+ }
+
+ if let err = String(data: stdErr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8), !err.isEmpty {
+ log("Archive stream failed(candidate=\(candidate)): \(err)", level: .warn)
+ }
+ }
+
+ return nil
+}
+
+func getArchiveEntryDataIfNeeded(url: URL) -> Data? {
+ guard let parsed = parseVirtualArchivePath(url.absoluteString),
+ let entryPath = parsed.entryPath else {
+ return nil
+ }
+ return getArchiveEntryData(archiveURL: parsed.archiveURL, entryPath: entryPath)
+}
+
+private func normalizeFavoriteFolderPath(_ rawPath: String) -> String? {
+ guard let rawURL = URL(string: getFileSchemeAbsPath(rawPath)) else { return nil }
+ if rawURL.hasDirectoryPath {
+ return rawURL.absoluteString
+ }
+ return rawURL.deletingLastPathComponent().absoluteString
+}
+
+@discardableResult
+func addFavoritePath(_ rawPath: String) -> Bool {
+ guard let folderPath = normalizeFavoriteFolderPath(rawPath), !folderPath.isEmpty else { return false }
+ if globalVar.myFavoritesArray.contains(folderPath) {
+ return false
+ }
+ globalVar.myFavoritesArray.append(folderPath)
+ UserDefaults.standard.set(globalVar.myFavoritesArray, forKey: "globalVar.myFavoritesArray")
+ return true
+}
+
+@discardableResult
+func removeFavoritePath(_ rawPath: String) -> Bool {
+ guard let folderPath = normalizeFavoriteFolderPath(rawPath), !folderPath.isEmpty else { return false }
+ guard let index = globalVar.myFavoritesArray.firstIndex(of: folderPath) else { return false }
+ globalVar.myFavoritesArray.remove(at: index)
+ UserDefaults.standard.set(globalVar.myFavoritesArray, forKey: "globalVar.myFavoritesArray")
+ return true
+}
+
+func isFavoritePath(_ rawPath: String) -> Bool {
+ guard let folderPath = normalizeFavoriteFolderPath(rawPath), !folderPath.isEmpty else { return false }
+ return globalVar.myFavoritesArray.contains(folderPath)
+}
+
func getFileSchemeAbsParentFolderPath(_ path: String) -> String {
var pathNoScheme = path.hasPrefix("file://") ? String(path.dropFirst("file://".count)) : path
pathNoScheme = pathNoScheme.removingPercentEncoding!.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)!
@@ -331,7 +617,8 @@ func showInformationLong(title: String, attributedMessage: NSAttributedString, w
let alert = NSAlert()
alert.messageText = title
alert.alertStyle = .informational
- alert.addButton(withTitle: NSLocalizedString("OK", comment: "确定"))
+ let okButton = alert.addButton(withTitle: NSLocalizedString("OK", comment: "确定"))
+ okButton.keyEquivalent = "\u{1b}"
alert.icon = NSImage(named: NSImage.infoName)
// 创建滚动视图
diff --git a/FlowVision/Sources/Common/DataModel.swift b/FlowVision/Sources/Common/DataModel.swift
index 029d003c..b6651289 100644
--- a/FlowVision/Sources/Common/DataModel.swift
+++ b/FlowVision/Sources/Common/DataModel.swift
@@ -525,6 +525,8 @@ class FileModel {
var imageInfo: ImageInfo?
var getThumbFailed = false
var finderTags: [String] = []
+ var childImageCount: Int?
+ var childVideoCount: Int?
}
class DirModel {
@@ -636,12 +638,10 @@ class TreeViewModel {
}
func hasSubdirectory(at folderURL: URL) -> Bool {
- if folderURL.path.hasPrefix("/VirtualFinderTagsFolder") {
- if folderURL.path == "/VirtualFinderTagsFolder" {
- return true
- }else{
- return false
- }
+ if folderURL.path.hasPrefix("/VirtualFinderTagsFolder")
+ || folderURL.path.hasPrefix("/VirtualFavoritesFolder")
+ || folderURL.path.hasPrefix("/VirtualHistoryFolder") {
+ return folderURL.lastPathComponent.hasPrefix("Virtual")
}
let fileManager = FileManager.default
@@ -677,6 +677,29 @@ class TreeViewModel {
contents.append(tagURL)
}
}
+ } else if folderURL.path == "/VirtualFavoritesFolder" {
+ let allFavorites = globalVar.myFavoritesArray.compactMap { URL(string: $0) }
+ let existingFavorites = allFavorites.filter { url in
+ if url.path == "/" { return true }
+ var isDirectory: ObjCBool = false
+ return FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) && isDirectory.boolValue
+ }
+ contents = existingFavorites
+ } else if folderURL.path == "/VirtualHistoryFolder" {
+ let history = viewController.publicVar.folderStepStack
+ var seen = Set()
+ var historyFolders: [URL] = []
+ for path in history {
+ guard let url = URL(string: path) else { continue }
+ if isVirtualFolderPath(url.absoluteString) { continue }
+ if seen.contains(url.absoluteString) { continue }
+ var isDirectory: ObjCBool = false
+ if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), isDirectory.boolValue {
+ historyFolders.append(url)
+ seen.insert(url.absoluteString)
+ }
+ }
+ contents = historyFolders
} else if folderURL.path != "root" {
contents = try FileManager.default.contentsOfDirectory(at: folderURL, includingPropertiesForKeys: [.isDirectoryKey, .isUbiquitousItemKey, .isHiddenKey, .contentModificationDateKey, .creationDateKey, .addedToDirectoryDateKey], options: [])
}else{
@@ -716,7 +739,7 @@ class TreeViewModel {
// 过滤隐藏文件
// Filter hidden files
contents = contents.filter { url in
- if url.path.hasPrefix("/VirtualFinderTagsFolder") { return true }
+ if isVirtualFolderPath(url.absoluteString) { return true }
// 获取隐藏属性
// Get hidden attribute
@@ -743,7 +766,7 @@ class TreeViewModel {
// 过滤出目录列表
// Filter out directory list
var subFolders = contents.filter { url in
- if url.path.hasPrefix("/VirtualFinderTagsFolder") { return true }
+ if isVirtualFolderPath(url.absoluteString) { return true }
guard let isDirectoryResourceValue = try? url.resourceValues(forKeys: [.isDirectoryKey]), let isDirectory = isDirectoryResourceValue.isDirectory else {
return false
}
@@ -754,7 +777,9 @@ class TreeViewModel {
// Sort
// 卷列表保持字母序
// Volume list maintains alphabetical order
- if folderURL.path.hasPrefix("/VirtualFinderTagsFolder") {
+ if folderURL.path.hasPrefix("/VirtualFinderTagsFolder")
+ || folderURL.path.hasPrefix("/VirtualFavoritesFolder")
+ || folderURL.path.hasPrefix("/VirtualHistoryFolder") {
// 不排序,保持 FinderTag.all 的顺序
// No sorting, keep FinderTag.all order
} else if folderURL.path == "root" {
@@ -808,7 +833,11 @@ class TreeViewModel {
if folderURL.path == "root" {
let finderTagsURL = URL(string: "file:///VirtualFinderTagsFolder/")!
+ let favoritesURL = URL(string: "file:///VirtualFavoritesFolder/")!
+ let historyURL = URL(string: "file:///VirtualHistoryFolder/")!
subFolders.insert(finderTagsURL, at: 0)
+ subFolders.insert(historyURL, at: 0)
+ subFolders.insert(favoritesURL, at: 0)
}
if globalVar.autoHideToolbar && folderURL.path == "root" {
@@ -832,6 +861,10 @@ class TreeViewModel {
}else{
}
+ } else if subFolder.absoluteString == "file:///VirtualFavoritesFolder/" {
+ name = NSLocalizedString("Favorites", comment: "收藏")
+ } else if subFolder.absoluteString == "file:///VirtualHistoryFolder/" {
+ name = NSLocalizedString("History", comment: "历史")
}
var newNode = TreeNode(name: name, fullPath: fullPath)
diff --git a/FlowVision/Sources/Common/FFmpegKit.swift b/FlowVision/Sources/Common/FFmpegKit.swift
index c9575364..cb232202 100644
--- a/FlowVision/Sources/Common/FFmpegKit.swift
+++ b/FlowVision/Sources/Common/FFmpegKit.swift
@@ -66,6 +66,7 @@ class FFmpegKitWrapper {
func executeFFmpegCommand(_ command: [String]) -> Any? {
loadFFmpegKitIfNeeded()
lock.lock()
+ defer { lock.unlock() }
let className = "FFmpegKit"
let selectorName = "executeWithArguments:"
@@ -83,16 +84,15 @@ class FFmpegKitWrapper {
let methodIMP = ffmpegKitClass.method(for: selector)
typealias ExecuteFunctionType = @convention(c) (AnyClass, Selector, NSArray) -> Any
let executeFunction = unsafeBitCast(methodIMP, to: ExecuteFunctionType.self)
-
+
let args = NSArray(array: command)
-
- lock.unlock()
return executeFunction(ffmpegKitClass, selector, args)
}
func executeFFprobeCommand(_ command: [String]) -> Any? {
loadFFmpegKitIfNeeded()
lock.lock()
+ defer { lock.unlock() }
let className = "FFprobeKit"
let selectorName = "executeWithArguments:"
@@ -110,16 +110,15 @@ class FFmpegKitWrapper {
let methodIMP = ffprobeKitClass.method(for: selector)
typealias ExecuteFunctionType = @convention(c) (AnyClass, Selector, NSArray) -> Any
let executeFunction = unsafeBitCast(methodIMP, to: ExecuteFunctionType.self)
-
+
let args = NSArray(array: command)
-
- lock.unlock()
return executeFunction(ffprobeKitClass, selector, args)
}
func getReturnCode(from session: Any) -> Any? {
loadFFmpegKitIfNeeded()
lock.lock()
+ defer { lock.unlock() }
let selectorName = "getReturnCode"
let selector = sel_registerName(selectorName)
@@ -136,14 +135,13 @@ class FFmpegKitWrapper {
let methodIMP = sessionClass.instanceMethod(for: selector)
typealias GetReturnCodeFunctionType = @convention(c) (AnyObject, Selector) -> Any?
let getReturnCodeFunction = unsafeBitCast(methodIMP, to: GetReturnCodeFunctionType.self)
-
- lock.unlock()
return getReturnCodeFunction(session as AnyObject, selector)
}
func getOutput(from session: Any) -> String? {
loadFFmpegKitIfNeeded()
lock.lock()
+ defer { lock.unlock() }
let selectorName = "getOutput"
let selector = sel_registerName(selectorName)
@@ -160,14 +158,13 @@ class FFmpegKitWrapper {
let methodIMP = sessionClass.instanceMethod(for: selector)
typealias GetOutputFunctionType = @convention(c) (AnyObject, Selector) -> String?
let getOutputFunction = unsafeBitCast(methodIMP, to: GetOutputFunctionType.self)
-
- lock.unlock()
return getOutputFunction(session as AnyObject, selector)
}
func isSuccess(_ returnCode: Any?) -> Bool {
loadFFmpegKitIfNeeded()
lock.lock()
+ defer { lock.unlock() }
let className = "ReturnCode"
let selectorName = "isSuccess:"
@@ -185,8 +182,6 @@ class FFmpegKitWrapper {
let methodIMP = returnCodeClass.method(for: selector)
typealias IsSuccessFunctionType = @convention(c) (AnyClass, Selector, Any) -> Bool
let isSuccessFunction = unsafeBitCast(methodIMP, to: IsSuccessFunctionType.self)
-
- lock.unlock()
return isSuccessFunction(returnCodeClass, selector, returnCode as Any)
}
}
diff --git a/FlowVision/Sources/Common/GlobalVariable.swift b/FlowVision/Sources/Common/GlobalVariable.swift
index 0dfce637..fb142919 100644
--- a/FlowVision/Sources/Common/GlobalVariable.swift
+++ b/FlowVision/Sources/Common/GlobalVariable.swift
@@ -84,11 +84,14 @@ class GlobalVar{
var blackBgAlways = false
var blackBgAlwaysForVideo = true
var thumbnailOfFolderUseStacking = true
+ var showFolderMediaCountBadge = true
+ var cacheExternalFolderThumbnails = true
var thumbnailExcludeList: [String] = []
var usePinyinSearch = false
var usePinyinInitialSearch = false
var videoPlayRememberPosition = false
var videoPlaySequentialPlay = false
+ var preferIINAForExternalVideoPlayer = true
var useInternalPlayer = true {
didSet {
useInternalPlayerCheckbox?.state = useInternalPlayer ? .on : .off
@@ -104,6 +107,15 @@ class GlobalVar{
var dirTreeAutoExpand = true
var largeImageViewShowTagsAndRating = true
var enhancedIndexEnabled = true
+ var photoFolder1Path: String = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first?.path ?? homeDirectory
+ var photoFolder1CopyShortcut: String = "N"
+ var photoFolder2Path: String = FileManager.default.urls(for: .moviesDirectory, in: .userDomainMask).first?.path ?? FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first?.path ?? homeDirectory
+ var photoFolder2CopyShortcut: String = "F4"
+ var quickRenameRule: String = "{folder}_{index}"
+ var videoShiftArrowSwitchFile = true
+ var showArchiveFileType = true
+ var compressionDefaultPassword: String = ""
+ var compressionUseDefaultPassword = false
var collectionViewItemShowTooltip = true
// 可记忆设置变量
@@ -208,4 +220,3 @@ func getSystemVolumeName() -> String? {
return nil
}
}
-
diff --git a/FlowVision/Sources/Common/ImageProcess.swift b/FlowVision/Sources/Common/ImageProcess.swift
index ed2d7acb..75506047 100644
--- a/FlowVision/Sources/Common/ImageProcess.swift
+++ b/FlowVision/Sources/Common/ImageProcess.swift
@@ -9,6 +9,120 @@ import AVFoundation
import Vision
import SDWebImageWebPCoder
+private func loadDataForImageURL(_ url: URL) -> Data? {
+ if let archiveData = getArchiveEntryDataIfNeeded(url: url) {
+ return archiveData
+ }
+ return try? Data(contentsOf: url)
+}
+
+private func loadImageSourceSmart(url: URL, options: CFDictionary? = nil) -> CGImageSource? {
+ if let data = getArchiveEntryDataIfNeeded(url: url) {
+ return CGImageSourceCreateWithData(data as CFData, options)
+ }
+ return CGImageSourceCreateWithURL(url as CFURL, options)
+}
+
+private func loadNSImageSmart(url: URL) -> NSImage? {
+ if let data = getArchiveEntryDataIfNeeded(url: url) {
+ return NSImage(data: data)
+ }
+ return NSImage(contentsOf: url)
+}
+
+enum FolderThumbnailDiskCache {
+ private static let cacheVersion = "v1"
+ private static let cacheFolderName = "ExternalFolderThumbnailCache"
+ private static let compressionFactor: CGFloat = 0.72
+
+ static var cacheDirectory: URL {
+ let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
+ ?? FileManager.default.temporaryDirectory
+ return baseURL
+ .appendingPathComponent("FlowVision", isDirectory: true)
+ .appendingPathComponent(cacheFolderName, isDirectory: true)
+ }
+
+ static func cachedImage(for folderURL: URL) -> NSImage? {
+ guard globalVar.cacheExternalFolderThumbnails else { return nil }
+ let url = cacheFileURL(for: folderURL)
+ return NSImage(contentsOf: url)
+ }
+
+ static func store(_ image: NSImage, for folderURL: URL) {
+ guard globalVar.cacheExternalFolderThumbnails,
+ let data = jpegData(from: image) else { return }
+ do {
+ try FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
+ try data.write(to: cacheFileURL(for: folderURL), options: .atomic)
+ } catch {
+ log("Failed to write folder thumbnail cache: \(error)", level: .warn)
+ }
+ }
+
+ static func sizeInBytes() -> Int64 {
+ guard let enumerator = FileManager.default.enumerator(
+ at: cacheDirectory,
+ includingPropertiesForKeys: [.isRegularFileKey, .fileSizeKey],
+ options: [.skipsHiddenFiles]
+ ) else {
+ return 0
+ }
+
+ var total: Int64 = 0
+ for case let fileURL as URL in enumerator {
+ guard let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]),
+ values.isRegularFile == true else { continue }
+ total += Int64(values.fileSize ?? 0)
+ }
+ return total
+ }
+
+ static func clear() {
+ do {
+ if FileManager.default.fileExists(atPath: cacheDirectory.path) {
+ try FileManager.default.removeItem(at: cacheDirectory)
+ }
+ try FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
+ } catch {
+ log("Failed to clear folder thumbnail cache: \(error)", level: .warn)
+ }
+ }
+
+ private static func cacheFileURL(for folderURL: URL) -> URL {
+ cacheDirectory.appendingPathComponent("\(stableHash(cacheKey(for: folderURL))).jpg")
+ }
+
+ private static func cacheKey(for folderURL: URL) -> String {
+ let values = try? folderURL.resourceValues(forKeys: [.contentModificationDateKey])
+ let modificationTime = values?.contentModificationDate?.timeIntervalSince1970 ?? 0
+ return [
+ cacheVersion,
+ folderURL.standardizedFileURL.path,
+ String(modificationTime),
+ String(globalVar.folderSearchDepth_External),
+ String(globalVar.thumbnailOfFolderUseStacking)
+ ].joined(separator: "|")
+ }
+
+ private static func stableHash(_ string: String) -> String {
+ var hash: UInt64 = 0xcbf29ce484222325
+ for byte in string.utf8 {
+ hash ^= UInt64(byte)
+ hash &*= 0x100000001b3
+ }
+ return String(format: "%016llx", hash)
+ }
+
+ private static func jpegData(from image: NSImage) -> Data? {
+ guard let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
+ return nil
+ }
+ let representation = NSBitmapImageRep(cgImage: cgImage)
+ return representation.representation(using: .jpeg, properties: [.compressionFactor: compressionFactor])
+ }
+}
+
extension NSImage {
func rotated(by degrees: CGFloat) -> NSImage {
if degrees == 0 { return self }
@@ -811,7 +925,11 @@ func getImageThumb(url: URL, size oriSize: NSSize? = nil, refSize: NSSize? = nil
if(url.hasDirectoryPath){
var urls = [URL]()
- let folderSearchDepth = VolumeManager.shared.isExternalVolume(url) ? globalVar.folderSearchDepth_External : globalVar.folderSearchDepth
+ let isExternalVolume = VolumeManager.shared.isExternalVolume(url)
+ if isExternalVolume, let cachedImage = FolderThumbnailDiskCache.cachedImage(for: url) {
+ return cachedImage
+ }
+ let folderSearchDepth = isExternalVolume ? globalVar.folderSearchDepth_External : globalVar.folderSearchDepth
if folderSearchDepth > 0 {
let maxImages = globalVar.thumbnailOfFolderUseStacking ? 3 : 4
urls = findImageURLs(in: url, maxDepth: folderSearchDepth, maxImages: maxImages, preferDifferentDirs: !globalVar.thumbnailOfFolderUseStacking)
@@ -832,6 +950,9 @@ func getImageThumb(url: URL, size oriSize: NSSize? = nil, refSize: NSSize? = nil
}
if imgs.count>0 {
let finalImg=createCompositeImage(background: NSImage(named: NSImage.folderName)!, images: imgs, isVideos: isVideos)
+ if let finalImg, isExternalVolume {
+ FolderThumbnailDiskCache.store(finalImg, for: url)
+ }
return finalImg
}
}
@@ -919,7 +1040,7 @@ func getImageThumb(url: URL, size oriSize: NSSize? = nil, refSize: NSSize? = nil
// 使用原图的格式
// Use original image format
if ["gif", "svg"].contains(url.pathExtension.lowercased()) {
- return NSImage(contentsOf: url)
+ return loadNSImageSmart(url: url)
}
// 若指定了大小则特殊处理
// Special handling if size is specified
@@ -937,7 +1058,7 @@ func getImageThumb(url: URL, size oriSize: NSSize? = nil, refSize: NSSize? = nil
let myOptions = [kCGImageSourceShouldCache : kCFBooleanFalse] as CFDictionary;
- guard let myImageSource = CGImageSourceCreateWithURL(url as NSURL, myOptions) else {
+ guard let myImageSource = loadImageSourceSmart(url: url, options: myOptions) else {
log("Image source is NULL.", level: .warn);
// return getFileTypeIcon(url: url)
return nil
@@ -1002,7 +1123,7 @@ func getFullExifThumbnail(url: URL, size oriSize: NSSize? = nil, rotate: Int = 0
let myOptions = [kCGImageSourceShouldCache : kCFBooleanFalse] as CFDictionary;
- guard let myImageSource = CGImageSourceCreateWithURL(url as NSURL, myOptions) else {
+ guard let myImageSource = loadImageSourceSmart(url: url, options: myOptions) else {
log("Image source is NULL.", level: .warn);
// return getFileTypeIcon(url: url)
return nil
@@ -1086,7 +1207,7 @@ func newOrientation(currentOrientation: Int, rotate: Int) -> Int {
func getAnimateImage(url: URL, size: NSSize? = nil, rotate: Int = 0) -> NSImage? {
if ["webp"].contains(url.pathExtension.lowercased()) && rotate == 0 {
- if let data = try? Data(contentsOf: url),
+ if let data = loadDataForImageURL(url),
let source = CGImageSourceCreateWithData(data as CFData, nil),
CGImageSourceGetCount(source) > 1 {
var options:[SDImageCoderOption: Any] = [:]
@@ -1100,10 +1221,10 @@ func getAnimateImage(url: URL, size: NSSize? = nil, rotate: Int = 0) -> NSImage?
}
if ["png"].contains(url.pathExtension.lowercased()) && rotate == 0 {
- if let data = try? Data(contentsOf: url),
+ if let data = loadDataForImageURL(url),
let source = CGImageSourceCreateWithData(data as CFData, nil),
CGImageSourceGetCount(source) > 1 {
- return NSImage(contentsOf: url)
+ return loadNSImageSmart(url: url)
}
}
@@ -1128,7 +1249,7 @@ func getResizedImage(url: URL, size oriSize: NSSize, rotate: Int = 0, isRawUseEm
return animateImage
}
- guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
+ guard let imageSource = loadImageSourceSmart(url: url, options: nil),
let image = CGImageSourceCreateImageAtIndex(imageSource, 0, nil)
else {
print("Failed when imageSource:",url.absoluteString.removingPercentEncoding!)
@@ -1742,7 +1863,7 @@ func getImageInfo(url: URL, needMetadata: Bool) -> ImageInfo? {
if let thumb = getImageThumb(url: url) {return ImageInfo(thumb.size)}
return nil
}else if globalVar.HandledImageAndRawExtensions.contains(url.pathExtension.lowercased()){
- guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
+ guard let imageSource = loadImageSourceSmart(url: url, options: nil) else { return nil }
guard let imageProperties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [String: Any] else { return nil }
guard let width = imageProperties[kCGImagePropertyPixelWidth as String] as? CGFloat,
let height = imageProperties[kCGImagePropertyPixelHeight as String] as? CGFloat else { return nil }
@@ -2108,7 +2229,7 @@ func formatExifData(_ imageProperties: [String: Any], isVideo: Bool, needWarp: B
}
func readRating(from imageURL: URL) -> Int? {
- guard let imageSource = CGImageSourceCreateWithURL(imageURL as CFURL, nil) else { return nil }
+ guard let imageSource = loadImageSourceSmart(url: imageURL) else { return nil }
guard let metadata = CGImageSourceCopyMetadataAtIndex(imageSource, 0, nil) else { return nil }
//let namespace = "http://ns.adobe.com/xap/1.0/"
@@ -2307,12 +2428,12 @@ class LargeImageProcessor {
if let animateImage = getAnimateImage(url: url, rotate: rotate) {
image = animateImage
} else {
- image = NSImage(contentsOf: url)?.rotated(by: CGFloat(-90*rotate))
+ image = loadNSImageSmart(url: url)?.rotated(by: CGFloat(-90*rotate))
}
}else{
image = getResizedImage(url: url, size: size, rotate: rotate, isRawUseEmbeddedThumb: isRawUseEmbeddedThumb)
if image == nil {
- image = NSImage(contentsOf: url)?.rotated(by: CGFloat(-90*rotate))
+ image = loadNSImageSmart(url: url)?.rotated(by: CGFloat(-90*rotate))
}
}
diff --git a/FlowVision/Sources/Common/UpdateManager.swift b/FlowVision/Sources/Common/UpdateManager.swift
new file mode 100644
index 00000000..20e2b92a
--- /dev/null
+++ b/FlowVision/Sources/Common/UpdateManager.swift
@@ -0,0 +1,201 @@
+//
+// UpdateManager.swift
+// FlowVision
+//
+
+import Cocoa
+
+final class FlowVisionUpdateManager {
+ static let shared = FlowVisionUpdateManager()
+
+ static let repositoryURL = URL(string: "https://github.com/mcxen/flowvision")!
+ static let latestReleaseURL = URL(string: "https://github.com/mcxen/flowvision/releases/latest")!
+ static let stableDownloadURL = URL(string: "https://github.com/mcxen/flowvision/releases/latest/download/FlowVision-macOS.zip")!
+
+ enum State: Equatable {
+ case idle
+ case checking
+ case upToDate
+ case available(String)
+ case installing
+ case failed(String)
+ }
+
+ private(set) var state: State = .idle
+
+ private init() {}
+
+ static func version(fromReleaseURL url: URL) -> String? {
+ let components = url.pathComponents.filter { $0 != "/" }
+ guard let releasesIndex = components.firstIndex(of: "releases"),
+ components.indices.contains(releasesIndex + 2),
+ components[releasesIndex + 1] == "tag" else { return nil }
+ let tag = components[releasesIndex + 2]
+ let version = tag.hasPrefix("v") ? String(tag.dropFirst()) : tag
+ guard !version.isEmpty,
+ version.split(separator: ".", omittingEmptySubsequences: false).allSatisfy({ Int($0) != nil }) else {
+ return nil
+ }
+ return version
+ }
+
+ static func isVersion(_ candidate: String, newerThan current: String) -> Bool {
+ let candidateParts = candidate.split(separator: ".").map { Int($0) ?? 0 }
+ let currentParts = current.split(separator: ".").map { Int($0) ?? 0 }
+ let count = max(candidateParts.count, currentParts.count)
+ for index in 0.. rhs }
+ }
+ return false
+ }
+
+ func checkForUpdates(manual: Bool) {
+ guard state != .checking, state != .installing else { return }
+ state = .checking
+
+ var request = URLRequest(url: Self.latestReleaseURL)
+ request.httpMethod = "HEAD"
+ request.timeoutInterval = 15
+ request.setValue("FlowVision-Updater/\(currentVersion)", forHTTPHeaderField: "User-Agent")
+
+ URLSession.shared.dataTask(with: request) { [weak self] _, response, error in
+ DispatchQueue.main.async {
+ guard let self else { return }
+ if let error {
+ self.fail(error.localizedDescription, showAlert: manual)
+ return
+ }
+ guard let finalURL = response?.url,
+ let latestVersion = Self.version(fromReleaseURL: finalURL) else {
+ self.fail(self.localized(
+ english: "GitHub returned an invalid latest-release address.",
+ simplifiedChinese: "GitHub 返回的最新版本地址无效。",
+ traditionalChinese: "GitHub 傳回的最新版本位址無效。"
+ ), showAlert: manual)
+ return
+ }
+
+ if Self.isVersion(latestVersion, newerThan: self.currentVersion) {
+ self.state = .available(latestVersion)
+ self.presentAvailableUpdate(version: latestVersion)
+ } else {
+ self.state = .upToDate
+ if manual { self.presentUpToDate() }
+ }
+ }
+ }.resume()
+ }
+
+ private var currentVersion: String {
+ Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.0"
+ }
+
+ private func presentAvailableUpdate(version: String) {
+ let alert = NSAlert()
+ alert.messageText = localized(
+ english: "FlowVision \(version) is available",
+ simplifiedChinese: "FlowVision \(version) 已发布",
+ traditionalChinese: "FlowVision \(version) 已發佈"
+ )
+ alert.informativeText = localized(
+ english: "Current version: \(currentVersion). Download the latest release from GitHub and install it now?",
+ simplifiedChinese: "当前版本:\(currentVersion)。是否立即从 GitHub 下载最新版并安装?",
+ traditionalChinese: "目前版本:\(currentVersion)。是否立即從 GitHub 下載最新版並安裝?"
+ )
+ alert.alertStyle = .informational
+ alert.addButton(withTitle: localized(english: "Download and Install", simplifiedChinese: "下载并安装", traditionalChinese: "下載並安裝"))
+ alert.addButton(withTitle: localized(english: "Later", simplifiedChinese: "稍后", traditionalChinese: "稍後"))
+ if alert.runModal() == .alertFirstButtonReturn {
+ installLatestVersion()
+ }
+ }
+
+ private func presentUpToDate() {
+ let alert = NSAlert()
+ alert.messageText = localized(english: "FlowVision is up to date", simplifiedChinese: "FlowVision 已是最新版本", traditionalChinese: "FlowVision 已是最新版本")
+ alert.informativeText = localized(
+ english: "You are running FlowVision \(currentVersion).",
+ simplifiedChinese: "当前运行版本为 FlowVision \(currentVersion)。",
+ traditionalChinese: "目前執行版本為 FlowVision \(currentVersion)。"
+ )
+ alert.runModal()
+ }
+
+ private func installLatestVersion() {
+ let appURL = Bundle.main.bundleURL.standardizedFileURL
+ let parentURL = appURL.deletingLastPathComponent()
+ guard appURL.lastPathComponent == "FlowVision.app" else {
+ fail(localized(
+ english: "Automatic updates are available only from the packaged FlowVision.app.",
+ simplifiedChinese: "自动更新仅适用于正式打包的 FlowVision.app。",
+ traditionalChinese: "自動更新僅適用於正式封裝的 FlowVision.app。"
+ ), showAlert: true)
+ return
+ }
+ guard FileManager.default.isWritableFile(atPath: parentURL.path) else {
+ fail(localized(
+ english: "FlowVision cannot update this installation location. Download the latest release from GitHub, or upgrade it with the package manager that installed it.",
+ simplifiedChinese: "FlowVision 无法写入当前安装位置。请从 GitHub 下载最新版,或使用原安装工具升级。",
+ traditionalChinese: "FlowVision 無法寫入目前安裝位置。請從 GitHub 下載最新版,或使用原安裝工具升級。"
+ ), showAlert: true)
+ return
+ }
+
+ let helperURL = Bundle.main.bundleURL
+ .appendingPathComponent("Contents/MacOS/FlowVisionUpdater", isDirectory: false)
+ guard FileManager.default.isExecutableFile(atPath: helperURL.path) else {
+ fail(localized(
+ english: "The updater helper is missing from this copy of FlowVision.",
+ simplifiedChinese: "当前 FlowVision 缺少更新助手。",
+ traditionalChinese: "目前 FlowVision 缺少更新助手。"
+ ), showAlert: true)
+ return
+ }
+
+ let process = Process()
+ process.executableURL = helperURL
+ process.arguments = [
+ String(ProcessInfo.processInfo.processIdentifier),
+ Self.stableDownloadURL.absoluteString,
+ appURL.path
+ ]
+ do {
+ try process.run()
+ state = .installing
+ NSApp.terminate(nil)
+ } catch {
+ fail(error.localizedDescription, showAlert: true)
+ }
+ }
+
+ private func fail(_ message: String, showAlert: Bool) {
+ state = .failed(message)
+ guard showAlert else { return }
+ let alert = NSAlert()
+ alert.alertStyle = .warning
+ alert.messageText = localized(english: "Unable to update FlowVision", simplifiedChinese: "无法更新 FlowVision", traditionalChinese: "無法更新 FlowVision")
+ alert.informativeText = message
+ alert.addButton(withTitle: localized(english: "OK", simplifiedChinese: "好", traditionalChinese: "好"))
+ alert.addButton(withTitle: localized(english: "Open GitHub", simplifiedChinese: "打开 GitHub", traditionalChinese: "開啟 GitHub"))
+ if alert.runModal() == .alertSecondButtonReturn {
+ NSWorkspace.shared.open(Self.repositoryURL.appendingPathComponent("releases/latest"))
+ }
+ }
+
+ private func localized(
+ english: String,
+ simplifiedChinese: String,
+ traditionalChinese: String
+ ) -> String {
+ let language = Locale.preferredLanguages.first?.lowercased() ?? "en"
+ if language.hasPrefix("zh-hans") || language.hasPrefix("zh-cn") {
+ return simplifiedChinese
+ }
+ if language.hasPrefix("zh-hant") || language.hasPrefix("zh-tw") || language.hasPrefix("zh-hk") {
+ return traditionalChinese
+ }
+ return english
+ }
+}
diff --git a/FlowVision/Sources/Common/VideoProcess.swift b/FlowVision/Sources/Common/VideoProcess.swift
index d179fcfc..3d616406 100644
--- a/FlowVision/Sources/Common/VideoProcess.swift
+++ b/FlowVision/Sources/Common/VideoProcess.swift
@@ -8,6 +8,127 @@ import Cocoa
import AVFoundation
import AVKit
+/// Coordinates bounded, cancellable media preheating for one browser window.
+/// Image work fills FlowVision's decoded-image cache. Video work reads the
+/// first few seconds of compressed samples so SMB data lands in the macOS file
+/// cache, and retains the parsed asset for AVPlayer fallback.
+final class MediaPreheatManager {
+ private let imageQueue: OperationQueue = {
+ let queue = OperationQueue()
+ queue.name = "FlowVision.MediaPreheat.Images"
+ queue.qualityOfService = .utility
+ queue.maxConcurrentOperationCount = 2
+ return queue
+ }()
+
+ private let videoQueue: OperationQueue = {
+ let queue = OperationQueue()
+ queue.name = "FlowVision.MediaPreheat.Videos"
+ queue.qualityOfService = .utility
+ // Serial reads avoid turning SMB preheating into competing random I/O.
+ queue.maxConcurrentOperationCount = 1
+ return queue
+ }()
+
+ private let stateQueue = DispatchQueue(label: "FlowVision.MediaPreheat.State")
+ private var generation = 0
+ private var assets: [URL: AVURLAsset] = [:]
+
+ deinit {
+ imageQueue.cancelAllOperations()
+ videoQueue.cancelAllOperations()
+ }
+
+ /// Starts a new ±5 media window and invalidates work from the old position.
+ @discardableResult
+ func beginWindow(retaining urls: [URL]) -> Int {
+ imageQueue.cancelAllOperations()
+ videoQueue.cancelAllOperations()
+ let retainedURLs = Set(urls)
+ return stateQueue.sync {
+ generation += 1
+ assets = assets.filter { retainedURLs.contains($0.key) }
+ return generation
+ }
+ }
+
+ func scheduleImage(generation: Int, distance: Int, work: @escaping () -> Void) {
+ let operation = BlockOperation { [weak self] in
+ guard let self, self.isCurrent(generation) else { return }
+ autoreleasepool(invoking: work)
+ }
+ operation.queuePriority = queuePriority(for: distance)
+ imageQueue.addOperation(operation)
+ }
+
+ func scheduleVideo(url: URL, generation: Int, distance: Int, seconds: Double = 5) {
+ guard preheatedAsset(for: url) == nil else { return }
+ let operation = BlockOperation { [weak self] in
+ self?.preheatVideo(url: url, generation: generation, seconds: seconds)
+ }
+ operation.queuePriority = queuePriority(for: distance)
+ videoQueue.addOperation(operation)
+ }
+
+ func preheatedAsset(for url: URL) -> AVURLAsset? {
+ stateQueue.sync { assets[url] }
+ }
+
+ private func isCurrent(_ value: Int) -> Bool {
+ stateQueue.sync { generation == value }
+ }
+
+ private func queuePriority(for distance: Int) -> Operation.QueuePriority {
+ switch abs(distance) {
+ case 0...1: return .veryHigh
+ case 2: return .high
+ case 3: return .normal
+ default: return .low
+ }
+ }
+
+ private func preheatVideo(url: URL, generation: Int, seconds: Double) {
+ guard isCurrent(generation) else { return }
+
+ let asset = AVURLAsset(
+ url: url,
+ options: [AVURLAssetPreferPreciseDurationAndTimingKey: false]
+ )
+ guard let videoTrack = asset.tracks(withMediaType: .video).first,
+ isCurrent(generation)
+ else { return }
+
+ stateQueue.sync {
+ if self.generation == generation {
+ self.assets[url] = asset
+ }
+ }
+
+ do {
+ let reader = try AVAssetReader(asset: asset)
+ reader.timeRange = CMTimeRange(
+ start: .zero,
+ duration: CMTime(seconds: max(1, seconds), preferredTimescale: 600)
+ )
+ let output = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: nil)
+ output.alwaysCopiesSampleData = false
+ guard reader.canAdd(output) else { return }
+ reader.add(output)
+ guard reader.startReading() else { return }
+
+ while isCurrent(generation), output.copyNextSampleBuffer() != nil {
+ // Reading compressed samples intentionally warms the unified
+ // file cache; AVPlayer/mpv will decode them when playback starts.
+ }
+ if !isCurrent(generation) {
+ reader.cancelReading()
+ }
+ } catch {
+ log("Video preheat failed: \(url.lastPathComponent): \(error.localizedDescription)", level: .warn)
+ }
+ }
+}
+
class NoHitAVPlayerView: AVPlayerView {
override func hitTest(_ point: NSPoint) -> NSView? {
return superview?.hitTest(convert(point, to: superview))
@@ -15,9 +136,10 @@ class NoHitAVPlayerView: AVPlayerView {
}
class LargeAVPlayerView: AVPlayerView {
-// override func hitTest(_ point: NSPoint) -> NSView? {
-// return nil // superview?.hitTest(convert(point, to: superview))
-// }
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ return nil
+ }
+
override func scrollWheel(with event: NSEvent) {
// 不响应滚动事件,直接传递给下一个
// Don't respond to scroll events, pass directly to next responder
@@ -68,7 +190,10 @@ func createLoopingComposition(url: URL) -> AVMutableComposition? {
}
func getCommonTimeRange(url: URL) -> CMTimeRange? {
- let asset = AVAsset(url: url)
+ getCommonTimeRange(asset: AVAsset(url: url))
+}
+
+func getCommonTimeRange(asset: AVAsset) -> CMTimeRange? {
guard let videoTrack = asset.tracks(withMediaType: .video).first else {
return nil
}
@@ -83,4 +208,3 @@ func getCommonTimeRange(url: URL) -> CMTimeRange? {
// If no audio track, use video track's time range directly
return videoTrack.timeRange
}
-
diff --git a/FlowVision/Sources/SettingsViews/ActionsSettingsViewController.swift b/FlowVision/Sources/SettingsViews/ActionsSettingsViewController.swift
index 0e928263..9f3f4f36 100644
--- a/FlowVision/Sources/SettingsViews/ActionsSettingsViewController.swift
+++ b/FlowVision/Sources/SettingsViews/ActionsSettingsViewController.swift
@@ -15,12 +15,73 @@ final class ActionsSettingsViewController: NSViewController, SettingsPane {
@IBOutlet weak var radioEnterKeyRename: NSButton!
@IBOutlet weak var radioEnterKeyOpen: NSButton!
+
+ private var quickRenameRuleField = NSTextField()
+ private var photoFolder1PathField = NSTextField()
+ private var photoFolder1ShortcutPopup = NSPopUpButton()
+ private var photoFolder2PathField = NSTextField()
+ private var photoFolder2ShortcutPopup = NSPopUpButton()
+ private var shortcutConflictLabel = NSTextField(labelWithString: "")
+ private var videoShiftArrowSwitchFileCheckbox = NSButton()
+ private var showArchiveFileTypeCheckbox = NSButton()
+ private var compressionUseDefaultPasswordCheckbox = NSButton()
+ private var compressionDefaultPasswordField = NSSecureTextField()
+ private weak var guideGrid: NSGridView?
+ private var escMonitor: Any?
+
+ private let shortcutCandidates: [String] = {
+ let letters = (65...90).compactMap { UnicodeScalar($0).map { String($0) } }
+ let digits = (0...9).map(String.init)
+ let functionKeys = (1...12).map { "F\($0)" }
+ return letters + digits + ["=", "-", ",", ".", "[", "]"] + functionKeys
+ }()
+
+ private let reservedShortcutNotes: [(key: String, note: String)] = [
+ ("A", "上一项"),
+ ("D", "下一项"),
+ ("W", "放大或上移"),
+ ("S", "缩小或下移"),
+ ("Q", "左旋 / 快速搜索"),
+ ("E", "右旋"),
+ ("R", "重命名"),
+ ("F", "显示侧栏 / 镜像翻转"),
+ ("T", "窗口置顶"),
+ ("Z", "缩放到 100%"),
+ ("X", "缩放适合"),
+ (",", "视频 A 点"),
+ (".", "视频 B 点"),
+ ("J", "视频记忆播放位置"),
+ ("K", "视频 A-B 循环"),
+ ("L", "视频顺序播放"),
+ ("M", "移动到下载文件夹"),
+ ("U", "显示 / 隐藏界面"),
+ ("I", "信息 / EXIF"),
+ ("O", "OCR"),
+ ("P", "二维码"),
+ ("SPACE", "打开 / 播放暂停"),
+ ("TAB", "切换焦点"),
+ ("DELETE", "移到废纸篓"),
+ ("F2", "重命名"),
+ ("F3", "搜索"),
+ ("F5", "刷新"),
+ ("=", "缩略图放大"),
+ ("-", "缩略图缩小"),
+ ("0", "重置缩略图大小"),
+ ("1", "最大化窗口"),
+ ("2", "合适窗口大小"),
+ ("3", "调整窗口至图片实际大小"),
+ ("4", "调整窗口至图片当前大小"),
+ ("5", "将窗口居中")
+ ]
override func viewDidLoad() {
super.viewDidLoad()
radioEnterKeyOpen.state = globalVar.isEnterKeyToOpen ? .on : .off
radioEnterKeyRename.state = globalVar.isEnterKeyToOpen ? .off : .on
+
+ collapseGuideSection()
+ setupInlineFileActionSettingsPanel()
// MARK: RTL support
if let container = radioEnterKeyRename.superview {
@@ -28,6 +89,20 @@ final class ActionsSettingsViewController: NSViewController, SettingsPane {
}
}
+ override func viewDidAppear() {
+ super.viewDidAppear()
+ installEscMonitorIfNeeded()
+ }
+
+ override func viewWillDisappear() {
+ super.viewWillDisappear()
+ removeEscMonitor()
+ }
+
+ deinit {
+ removeEscMonitor()
+ }
+
@IBAction func enterKeyToOpenToggled(_ sender: NSButton) {
let tag = sender.tag
if tag == 0 {
@@ -37,4 +112,407 @@ final class ActionsSettingsViewController: NSViewController, SettingsPane {
}
UserDefaults.standard.set(globalVar.isEnterKeyToOpen, forKey: "isEnterKeyToOpen")
}
+
+ private func setupInlineFileActionSettingsPanel() {
+ guard let grid = guideGrid ?? view.subviews.compactMap({ $0 as? NSGridView }).first else { return }
+
+ let profileTitle = NSLocalizedString("Profile Switching:", comment: "配置切换:")
+ let targetRow = (0.. String {
+ let custom1 = "图片文件夹1:\(globalVar.photoFolder1CopyShortcut)"
+ let custom2 = "视频文件夹2:\(globalVar.photoFolder2CopyShortcut)"
+ let builtins = reservedShortcutNotes.map { "\($0.key) \($0.note)" }.joined(separator: "\n")
+ return ([custom1, custom2, "内置快捷键:", builtins]).joined(separator: "\n")
+ }
+
+ private func installEscMonitorIfNeeded() {
+ guard escMonitor == nil else { return }
+ escMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
+ guard let self else { return event }
+ guard event.keyCode == 53 else { return event }
+ guard let window = self.view.window, window.isKeyWindow else { return event }
+ guard NSApp.modalWindow == nil else { return event }
+ window.performClose(nil)
+ return nil
+ }
+ }
+
+ private func removeEscMonitor() {
+ if let escMonitor {
+ NSEvent.removeMonitor(escMonitor)
+ self.escMonitor = nil
+ }
+ }
}
diff --git a/FlowVision/Sources/SettingsViews/AdvancedSettingsViewController.swift b/FlowVision/Sources/SettingsViews/AdvancedSettingsViewController.swift
index 2a100e18..2c4ea2f6 100755
--- a/FlowVision/Sources/SettingsViews/AdvancedSettingsViewController.swift
+++ b/FlowVision/Sources/SettingsViews/AdvancedSettingsViewController.swift
@@ -33,6 +33,8 @@ final class AdvancedSettingsViewController: NSViewController, SettingsPane {
@IBOutlet weak var searchDepthWarningText: NSTextField!
@IBOutlet weak var searchDepthWarningText_External: NSTextField!
+
+ private var externalFolderThumbnailCacheSizeLabel: NSTextField?
override func viewDidLoad() {
super.viewDidLoad()
@@ -79,7 +81,14 @@ final class AdvancedSettingsViewController: NSViewController, SettingsPane {
if let container = memUseLimitSlider.superview {
convertToLeadingLayoutForRTL(container)
}
+
+ setupExternalFolderThumbnailCacheControls()
}
+
+ override func viewWillAppear() {
+ super.viewWillAppear()
+ updateExternalFolderThumbnailCacheSizeLabel()
+ }
@IBAction func memUseLimitSliderChanged(_ sender: NSSlider) {
let newValue = sender.integerValue
@@ -180,4 +189,62 @@ final class AdvancedSettingsViewController: NSViewController, SettingsPane {
doNotUseFFmpegRadioButton.state = globalVar.doNotUseFFmpeg ? .on : .off
}
+ private func setupExternalFolderThumbnailCacheControls() {
+ guard let gridView = view.subviews.compactMap({ $0 as? NSGridView }).first,
+ gridView.numberOfRows > 14 else {
+ return
+ }
+
+ let label = NSTextField(labelWithString: NSLocalizedString("Folder Thumbnail Cache:", comment: "文件夹缩略图缓存"))
+ label.alignment = .right
+
+ let checkbox = NSButton(checkboxWithTitle: NSLocalizedString("Cache external folder thumbnails locally", comment: "本地缓存外接卷文件夹缩略图"), target: self, action: #selector(externalFolderThumbnailCacheToggled(_:)))
+ checkbox.state = globalVar.cacheExternalFolderThumbnails ? .on : .off
+
+ let sizeLabel = NSTextField(labelWithString: "")
+ sizeLabel.textColor = .secondaryLabelColor
+ sizeLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
+ sizeLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+ externalFolderThumbnailCacheSizeLabel = sizeLabel
+
+ let clearButton = NSButton(title: NSLocalizedString("Clear", comment: "清理"), target: self, action: #selector(clearExternalFolderThumbnailCache(_:)))
+ clearButton.bezelStyle = .rounded
+ clearButton.controlSize = .small
+ clearButton.font = .systemFont(ofSize: NSFont.smallSystemFontSize)
+
+ let sizeRow = NSStackView(views: [sizeLabel, clearButton])
+ sizeRow.orientation = .horizontal
+ sizeRow.alignment = .centerY
+ sizeRow.spacing = 8
+
+ let container = NSStackView(views: [checkbox, sizeRow])
+ container.orientation = .vertical
+ container.alignment = .leading
+ container.spacing = 4
+
+ let row = gridView.insertRow(at: 14, with: [label, container])
+ row.topPadding = 4
+ row.bottomPadding = 4
+ view.setFrameSize(NSSize(width: view.frame.width, height: view.frame.height + 52))
+ updateExternalFolderThumbnailCacheSizeLabel()
+ }
+
+ @objc private func externalFolderThumbnailCacheToggled(_ sender: NSButton) {
+ globalVar.cacheExternalFolderThumbnails = (sender.state == .on)
+ UserDefaults.standard.set(globalVar.cacheExternalFolderThumbnails, forKey: "cacheExternalFolderThumbnails")
+ }
+
+ @objc private func clearExternalFolderThumbnailCache(_ sender: NSButton) {
+ FolderThumbnailDiskCache.clear()
+ updateExternalFolderThumbnailCacheSizeLabel()
+ }
+
+ private func updateExternalFolderThumbnailCacheSizeLabel() {
+ let formatter = ByteCountFormatter()
+ formatter.allowedUnits = [.useKB, .useMB, .useGB]
+ formatter.countStyle = .file
+ let sizeText = formatter.string(fromByteCount: FolderThumbnailDiskCache.sizeInBytes())
+ externalFolderThumbnailCacheSizeLabel?.stringValue = String(format: NSLocalizedString("Local cache: %@", comment: "本地缓存大小"), sizeText)
+ }
+
}
diff --git a/FlowVision/Sources/SettingsViews/Base.lproj/ActionsSettingsViewController.xib b/FlowVision/Sources/SettingsViews/Base.lproj/ActionsSettingsViewController.xib
index 28ec954c..aac389ce 100644
--- a/FlowVision/Sources/SettingsViews/Base.lproj/ActionsSettingsViewController.xib
+++ b/FlowVision/Sources/SettingsViews/Base.lproj/ActionsSettingsViewController.xib
@@ -215,17 +215,9 @@ In image view: W/S zoom in/zoom out, Z zoom to 100%, X zoom to fit, A/D previous
-
+
-
-
- Press Opt + 1~9 to switch to the corresponding profile.
-Press Opt + Cmd + 1~9 to save the current layout and style to the corresponding profile.
-The configurations involved in switching include: whether to display the sidebar, view type, sorting method, thumbnail size, and custom styles (such as whether to display file names and the width of the thumbnail border).
-
-
-
-
+
diff --git a/FlowVision/Sources/SettingsViews/Base.lproj/CustomSettingsViewController.xib b/FlowVision/Sources/SettingsViews/Base.lproj/CustomSettingsViewController.xib
index da4117f2..a5a91231 100755
--- a/FlowVision/Sources/SettingsViews/Base.lproj/CustomSettingsViewController.xib
+++ b/FlowVision/Sources/SettingsViews/Base.lproj/CustomSettingsViewController.xib
@@ -23,6 +23,7 @@
+
@@ -136,6 +137,7 @@
+
@@ -277,7 +279,7 @@
- Since macOS natively supports only video formats like "mp4", "mov", "m2ts", "ts", "mpeg", "mpg", "m4v", and "vob", other formats still need to be played using an external player.
+ When the internal player is disabled, videos are opened with IINA first if installed, otherwise with the system default player.
@@ -448,6 +450,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/FlowVision/Sources/SettingsViews/CustomSettingsViewController.swift b/FlowVision/Sources/SettingsViews/CustomSettingsViewController.swift
index 633e0a1c..c5bdfffc 100644
--- a/FlowVision/Sources/SettingsViews/CustomSettingsViewController.swift
+++ b/FlowVision/Sources/SettingsViews/CustomSettingsViewController.swift
@@ -15,6 +15,7 @@ final class CustomSettingsViewController: NSViewController, SettingsPane {
@IBOutlet weak var randomFolderThumbCheckbox: NSButton!
@IBOutlet weak var thumbnailOfFolderUseStackingCheckbox: NSButton!
+ @IBOutlet weak var showFolderMediaCountBadgeCheckbox: NSButton!
@IBOutlet weak var loopBrowsingCheckbox: NSButton!
@IBOutlet weak var clickEdgeToSwitchImageCheckbox: NSButton!
@IBOutlet weak var scrollMouseWheelToZoomCheckbox: NSButton!
@@ -39,6 +40,7 @@ final class CustomSettingsViewController: NSViewController, SettingsPane {
randomFolderThumbCheckbox.state = globalVar.randomFolderThumb ? .on : .off
thumbnailOfFolderUseStackingCheckbox.state = globalVar.thumbnailOfFolderUseStacking ? .on : .off
+ showFolderMediaCountBadgeCheckbox.state = globalVar.showFolderMediaCountBadge ? .on : .off
loopBrowsingCheckbox.state = globalVar.loopBrowsing ? .on : .off
clickEdgeToSwitchImageCheckbox.state = globalVar.clickEdgeToSwitchImage ? .on : .off
scrollMouseWheelToZoomCheckbox.state = globalVar.scrollMouseWheelToZoom ? .on : .off
@@ -110,6 +112,12 @@ final class CustomSettingsViewController: NSViewController, SettingsPane {
globalVar.thumbnailOfFolderUseStacking = (sender.state == .on)
UserDefaults.standard.set(globalVar.thumbnailOfFolderUseStacking, forKey: "thumbnailOfFolderUseStacking")
}
+
+ @IBAction func showFolderMediaCountBadgeToggled(_ sender: NSButton) {
+ globalVar.showFolderMediaCountBadge = (sender.state == .on)
+ UserDefaults.standard.set(globalVar.showFolderMediaCountBadge, forKey: "showFolderMediaCountBadge")
+ refreshVisibleFolderMediaCountBadges()
+ }
@IBAction func loopBrowsingToggled(_ sender: NSButton) {
globalVar.loopBrowsing = (sender.state == .on)
@@ -225,6 +233,16 @@ final class CustomSettingsViewController: NSViewController, SettingsPane {
break
}
}
+
+ private func refreshVisibleFolderMediaCountBadges() {
+ guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return }
+ for windowController in appDelegate.windowControllers {
+ guard let viewController = windowController.contentViewController as? ViewController else { continue }
+ for item in viewController.collectionView.visibleItems() {
+ (item as? CustomCollectionViewItem)?.refreshFolderMediaCountBadge()
+ }
+ }
+ }
}
// MARK: - NSOutlineViewDataSource
diff --git a/FlowVision/Sources/SettingsViews/mul.lproj/ActionsSettingsViewController.xcstrings b/FlowVision/Sources/SettingsViews/mul.lproj/ActionsSettingsViewController.xcstrings
index 5c8ad7c4..3553598f 100644
--- a/FlowVision/Sources/SettingsViews/mul.lproj/ActionsSettingsViewController.xcstrings
+++ b/FlowVision/Sources/SettingsViews/mul.lproj/ActionsSettingsViewController.xcstrings
@@ -543,7 +543,7 @@
},
"kUa-1f-s4l.title" : {
"comment" : "Class = \"NSTextFieldCell\"; title = \"Press Opt + 1~9 to switch to the corresponding profile.\\nPress Opt + Cmd + 1~9 to save the current layout and style to the corresponding profile.\\nThe configurations involved in switching include: whether to display the sidebar, view type, sorting method, thumbnail size, and custom styles (such as whether to display file names and the width of the thumbnail border).\"; ObjectID = \"kUa-1f-s4l\";",
- "extractionState" : "extracted_with_value",
+ "extractionState" : "stale",
"localizations" : {
"ar" : {
"stringUnit" : {
diff --git a/FlowVision/Sources/SettingsViews/mul.lproj/CustomSettingsViewController.xcstrings b/FlowVision/Sources/SettingsViews/mul.lproj/CustomSettingsViewController.xcstrings
index 56fa8339..36ed365c 100644
--- a/FlowVision/Sources/SettingsViews/mul.lproj/CustomSettingsViewController.xcstrings
+++ b/FlowVision/Sources/SettingsViews/mul.lproj/CustomSettingsViewController.xcstrings
@@ -1190,108 +1190,108 @@
}
},
"Jbl-Lw-mRw.title" : {
- "comment" : "Class = \"NSTextFieldCell\"; title = \"Since macOS natively supports only video formats like \\\"mp4\\\", \\\"mov\\\", \\\"m2ts\\\", \\\"ts\\\", \\\"mpeg\\\", \\\"mpg\\\", \\\"m4v\\\", and \\\"vob\\\", other formats still need to be played using an external player.\"; ObjectID = \"Jbl-Lw-mRw\";",
+ "comment" : "Class = \"NSTextFieldCell\"; title = \"When the internal player is disabled, videos are opened with IINA first if installed, otherwise with the system default player.\"; ObjectID = \"Jbl-Lw-mRw\";",
"extractionState" : "extracted_with_value",
"localizations" : {
"ar" : {
"stringUnit" : {
- "state" : "translated",
+ "state" : "needs_review",
"value" : "نظرًا لأن macOS يدعم فقط تنسيقات الفيديو مثل \"mp4\" و\"mov\" و\"m2ts\" و\"ts\" و\"mpeg\" و\"mpg\" و\"m4v\" و\"vob\"، فلا تزال هناك حاجة لاستخدام مشغل خارجي لتشغيل التنسيقات الأخرى."
}
},
"de" : {
"stringUnit" : {
- "state" : "translated",
+ "state" : "needs_review",
"value" : "Da macOS von Haus aus nur Videoformate wie „mp4“, „mov“, „m2ts“, „ts“, „mpeg“, „mpg“, „m4v“ und „vob“ unterstützt, müssen andere Formate weiterhin mit einem externen Player abgespielt werden."
}
},
"en" : {
"stringUnit" : {
"state" : "new",
- "value" : "Since macOS natively supports only video formats like \"mp4\", \"mov\", \"m2ts\", \"ts\", \"mpeg\", \"mpg\", \"m4v\", and \"vob\", other formats still need to be played using an external player."
+ "value" : "When the internal player is disabled, videos are opened with IINA first if installed, otherwise with the system default player."
}
},
"es" : {
"stringUnit" : {
- "state" : "translated",
+ "state" : "needs_review",
"value" : "Dado que macOS solo admite de forma nativa formatos de video como \"mp4\", \"mov\", \"m2ts\", \"ts\", \"mpeg\", \"mpg\", \"m4v\" y \"vob\", otros formatos aún necesitan reproducirse usando un reproductor externo"
}
},
"fr" : {
"stringUnit" : {
- "state" : "translated",
+ "state" : "needs_review",
"value" : "macOS ne prenant en charge que les formats vidéo tels que \"mp4\", \"mov\", \"m2ts\", \"ts\", \"mpeg\", \"mpg\", \"m4v\" et \"vob\", d'autres formats doivent encore être lus avec un lecteur externe."
}
},
"it" : {
"stringUnit" : {
- "state" : "translated",
+ "state" : "needs_review",
"value" : "Poiché macOS supporta nativamente solo formati video come \"mp4\", \"mov\", \"m2ts\", \"ts\", \"mpeg\", \"mpg\", \"m4v\" e \"vob\", gli altri formati devono ancora essere riprodotti utilizzando un lettore esterno."
}
},
"ja" : {
"stringUnit" : {
- "state" : "translated",
+ "state" : "needs_review",
"value" : "macOSは「mp4」、「mov」、「m2ts」、「ts」、「mpeg」、「mpg」、「m4v」、「vob」などのビデオフォーマットのみをネイティブにサポートしているため、他のフォーマットは外部プレーヤーを使用して再生する必要があります"
}
},
"ko" : {
"stringUnit" : {
- "state" : "translated",
+ "state" : "needs_review",
"value" : "macOS는 \"mp4\", \"mov\", \"m2ts\", \"ts\", \"mpeg\", \"mpg\", \"m4v\", \"vob\"와 같은 비디오 형식만 기본적으로 지원하므로 다른 형식은 외부 플레이어를 사용하여 재생해야 합니다"
}
},
"nl" : {
"stringUnit" : {
- "state" : "translated",
+ "state" : "needs_review",
"value" : "Aangezien macOS van nature alleen videoformaten zoals \"mp4\", \"mov\", \"m2ts\", \"ts\", \"mpeg\", \"mpg\", \"m4v\" en \"vob\" ondersteunt, moeten andere formaten nog steeds worden afgespeeld met een externe speler."
}
},
"pl" : {
"stringUnit" : {
- "state" : "translated",
+ "state" : "needs_review",
"value" : "Ponieważ macOS natywnie obsługuje tylko formaty wideo takie jak \"mp4\", \"mov\", \"m2ts\", \"ts\", \"mpeg\", \"mpg\", \"m4v\" i \"vob\", inne formaty nadal muszą być odtwarzane za pomocą zewnętrznego odtwarzacza."
}
},
"pt-BR" : {
"stringUnit" : {
- "state" : "translated",
+ "state" : "needs_review",
"value" : "Como o macOS suporta nativamente apenas formatos de vídeo como \"mp4\", \"mov\", \"m2ts\", \"ts\", \"mpeg\", \"mpg\", \"m4v\" e \"vob\", outros formatos ainda precisam ser reproduzidos usando um player externo."
}
},
"pt-PT" : {
"stringUnit" : {
- "state" : "translated",
+ "state" : "needs_review",
"value" : "Como o macOS suporta nativamente apenas formatos de vídeo como \"mp4\", \"mov\", \"m2ts\", \"ts\", \"mpeg\", \"mpg\", \"m4v\" e \"vob\", outros formatos ainda precisam ser reproduzidos usando um player externo."
}
},
"ru" : {
"stringUnit" : {
- "state" : "translated",
+ "state" : "needs_review",
"value" : "Поскольку macOS изначально поддерживает только видеоформаты такие как \"mp4\", \"mov\", \"m2ts\", \"ts\", \"mpeg\", \"mpg\", \"m4v\" и \"vob\", другие форматы все же необходимо воспроизводить с помощью внешнего плеера."
}
},
"sv" : {
"stringUnit" : {
- "state" : "translated",
+ "state" : "needs_review",
"value" : "Eftersom macOS inbyggt endast stödjer videoformat som \"mp4\", \"mov\", \"m2ts\", \"ts\", \"mpeg\", \"mpg\", \"m4v\" och \"vob\", behöver andra format fortfarande spelas upp med en extern spelare"
}
},
"tr" : {
"stringUnit" : {
- "state" : "translated",
+ "state" : "needs_review",
"value" : "macOS, \"mp4\", \"mov\", \"m2ts\", \"ts\", \"mpeg\", \"mpg\", \"m4v\" ve \"vob\" gibi video formatlarını yerel olarak desteklediğinden, diğer formatların harici bir oynatıcı kullanılarak oynatılması gerekmektedir."
}
},
"zh-Hans" : {
"stringUnit" : {
- "state" : "translated",
+ "state" : "needs_review",
"value" : "由于macOS原生仅支持 \"mp4\", \"mov\", \"m2ts\", \"ts\", \"mpeg\", \"mpg\", \"m4v\", \"vob\" 视频,其它格式仍需使用外部播放器播放。"
}
},
"zh-Hant" : {
"stringUnit" : {
- "state" : "translated",
+ "state" : "needs_review",
"value" : "由於 macOS 僅原生支持 \"mp4\"、\"mov\"、\"m2ts\"、\"ts\"、\"mpeg\"、\"mpg\"、\"m4v\" 和 \"vob\" 等視頻格式,其他格式仍需使用外部播放器播放"
}
}
@@ -2593,6 +2593,18 @@
}
}
},
+ "YtK-ML-mGi.title" : {
+ "comment" : "Class = \"NSButtonCell\"; title = \"Show Folder Media Count Badge\"; ObjectID = \"YtK-ML-mGi\";",
+ "extractionState" : "extracted_with_value",
+ "localizations" : {
+ "en" : {
+ "stringUnit" : {
+ "state" : "new",
+ "value" : "Show Folder Media Count Badge"
+ }
+ }
+ }
+ },
"Z13-dJ-JBv.title" : {
"comment" : "Class = \"NSTextFieldCell\"; title = \"For directories in the exclusion list, images within them will not be used when generating thumbnails for themselves or their parent folders.\"; ObjectID = \"Z13-dJ-JBv\";",
"extractionState" : "extracted_with_value",
diff --git a/FlowVision/Sources/ViewController.swift b/FlowVision/Sources/ViewController.swift
index f98f8bef..4535376e 100644
--- a/FlowVision/Sources/ViewController.swift
+++ b/FlowVision/Sources/ViewController.swift
@@ -177,6 +177,8 @@ class PublicVar{
var folderStepForLocateTime: DispatchTime = .now()
var filesForLocateAfterChange = [String]()
var filesForLocateAfterChangeTime: DispatchTime = .now()
+ var collectionScrollRestoreAfterRefresh: (folderPath: String, origin: NSPoint)?
+ var collectionViewportAnchorAfterRefresh: (folderPath: String, filePath: String, offset: NSPoint)?
var isInFileOperation = false
var isLeftMouseDown: Bool = false
var isRightMouseDown: Bool = false
@@ -353,6 +355,7 @@ class ViewController: NSViewController, NSSplitViewDelegate, NSSearchFieldDelega
var largeImageLoadTask: DispatchWorkItem?
var largeImageLoadQueueLock = NSLock()
+ let mediaPreheatManager = MediaPreheatManager()
var lastDoNotGenResized = false
var lastResizeFailed = false
@@ -412,6 +415,7 @@ class ViewController: NSViewController, NSSplitViewDelegate, NSSearchFieldDelega
var dirURLCache: [URL] = []
var dirURLCacheParameters: Any = []
+ var archiveImageEntryCache: [String: [String]] = [:]
// 加载进度条
// Loading progress bar
@@ -2169,7 +2173,7 @@ class ViewController: NSViewController, NSSplitViewDelegate, NSSearchFieldDelega
// 虚拟Finder标签目录不监听
// VirtualFinderTagsFolder directory doesn't listen
- if path.hasPrefix("/VirtualFinderTagsFolder") {
+ if isVirtualFolderPath("file://\(path)") {
return
}
diff --git a/FlowVision/Sources/ViewControllerExtension/DirTree.swift b/FlowVision/Sources/ViewControllerExtension/DirTree.swift
index f3da95f2..6f9b06fc 100644
--- a/FlowVision/Sources/ViewControllerExtension/DirTree.swift
+++ b/FlowVision/Sources/ViewControllerExtension/DirTree.swift
@@ -115,12 +115,24 @@ extension ViewController {
// 标签
// Tags
- if path.hasPrefix("file:///VirtualFinderTagsFolder") {
+ if path.hasPrefix(VIRTUAL_FINDER_TAGS_PREFIX) {
if path == "file:///VirtualFinderTagsFolder/" {
targetPaths = [NSLocalizedString("Finder Tags", comment: "Finder标签")]
}else{
targetPaths = [NSLocalizedString("Finder Tags", comment: "Finder标签"), URL(string: path)!.lastPathComponent]
}
+ } else if path.hasPrefix(VIRTUAL_FAVORITES_PREFIX) {
+ if path == "file:///VirtualFavoritesFolder/" {
+ targetPaths = [NSLocalizedString("Favorites", comment: "收藏")]
+ } else if let url = URL(string: path) {
+ targetPaths = [NSLocalizedString("Favorites", comment: "收藏"), url.lastPathComponent]
+ }
+ } else if path.hasPrefix(VIRTUAL_HISTORY_PREFIX) {
+ if path == "file:///VirtualHistoryFolder/" {
+ targetPaths = [NSLocalizedString("History", comment: "历史")]
+ } else if let url = URL(string: path) {
+ targetPaths = [NSLocalizedString("History", comment: "历史"), url.lastPathComponent]
+ }
}
if targetPaths.isEmpty {
diff --git a/FlowVision/Sources/ViewControllerExtension/EventHandler.swift b/FlowVision/Sources/ViewControllerExtension/EventHandler.swift
index da972f40..539ca1b1 100644
--- a/FlowVision/Sources/ViewControllerExtension/EventHandler.swift
+++ b/FlowVision/Sources/ViewControllerExtension/EventHandler.swift
@@ -184,6 +184,12 @@ extension ViewController {
publicVar.setFileExtensions()
refreshCollectionView(needLoadThumbPriority: true)
}
+
+ func toggleShowArchiveFileType() {
+ globalVar.showArchiveFileType.toggle()
+ UserDefaults.standard.set(globalVar.showArchiveFileType, forKey: "showArchiveFileType")
+ refreshCollectionView(needLoadThumbPriority: true)
+ }
func togglePanWhenZoomed(){
publicVar.isPanWhenZoomed.toggle()
diff --git a/FlowVision/Sources/ViewControllerExtension/FileOperation.swift b/FlowVision/Sources/ViewControllerExtension/FileOperation.swift
index 4e762305..e62a9daa 100644
--- a/FlowVision/Sources/ViewControllerExtension/FileOperation.swift
+++ b/FlowVision/Sources/ViewControllerExtension/FileOperation.swift
@@ -7,16 +7,1359 @@ import Foundation
import Cocoa
import AVFoundation
import DiskArbitration
+import ImageIO
+import BTree
+
+enum BatchMediaRotation: Int {
+ case clockwise90 = 90
+ case clockwise180 = 180
+ case counterclockwise90 = -90
+ case restoreVideo = 1000
+
+ var imageDegrees: CGFloat {
+ switch self {
+ case .clockwise90:
+ return -90
+ case .clockwise180:
+ return 180
+ case .counterclockwise90:
+ return 90
+ case .restoreVideo:
+ return 0
+ }
+ }
+
+ var videoFilter: String {
+ switch self {
+ case .clockwise90:
+ return "transpose=1"
+ case .clockwise180:
+ return "transpose=1,transpose=1"
+ case .counterclockwise90:
+ return "transpose=2"
+ case .restoreVideo:
+ return ""
+ }
+ }
+}
+
+private final class BatchRenamePreviewDataSource: NSObject, NSTableViewDataSource, NSTableViewDelegate {
+ private let rows: [(original: String, renamed: String)]
+
+ init(mappings: [(original: String, renamed: String)]) {
+ self.rows = mappings
+ }
+
+ func numberOfRows(in tableView: NSTableView) -> Int {
+ rows.count
+ }
+
+ func tableView(
+ _ tableView: NSTableView,
+ viewFor tableColumn: NSTableColumn?,
+ row: Int
+ ) -> NSView? {
+ guard let tableColumn = tableColumn else { return nil }
+ let identifier = NSUserInterfaceItemIdentifier("BatchRenamePreviewCell-\(tableColumn.identifier.rawValue)")
+ let cell = (tableView.makeView(withIdentifier: identifier, owner: self) as? NSTableCellView) ?? NSTableCellView()
+ cell.identifier = identifier
+
+ if cell.textField == nil {
+ let textField = NSTextField(labelWithString: "")
+ textField.lineBreakMode = .byTruncatingMiddle
+ textField.translatesAutoresizingMaskIntoConstraints = false
+ cell.addSubview(textField)
+ NSLayoutConstraint.activate([
+ textField.leadingAnchor.constraint(equalTo: cell.leadingAnchor, constant: 8),
+ textField.trailingAnchor.constraint(equalTo: cell.trailingAnchor, constant: -8),
+ textField.centerYAnchor.constraint(equalTo: cell.centerYAnchor)
+ ])
+ cell.textField = textField
+ }
+
+ cell.textField?.stringValue = tableColumn.identifier.rawValue == "original" ? rows[row].original : rows[row].renamed
+ return cell
+ }
+}
extension ViewController {
+ enum CompressMode {
+ case plainZip
+ case encryptedZip(password: String)
+ }
+
+ private struct FileRenameMapping {
+ let from: URL
+ let to: URL
+ }
+
+ private struct PendingTempRename {
+ let tempURL: URL
+ let targetURL: URL
+ }
+
+ struct VideoCropRect {
+ let x: Int
+ let y: Int
+ let width: Int
+ let height: Int
+ }
+
+ func hasSelectedRotatableMedia() -> Bool {
+ publicVar.selectedUrls().contains { isRotatableMediaURL($0) }
+ }
+ func hasSelectedVideoMedia() -> Bool {
+ if publicVar.isInLargeView,
+ largeImageView.file.type == .video,
+ let url = URL(string: largeImageView.file.path) {
+ return isEditableVideoURL(url)
+ }
+ return publicVar.selectedUrls().contains {
+ isEditableVideoURL($0)
+ }
+ }
+
+ func handleBatchCropSelectedVideos() {
+ let urls: [URL]
+ if publicVar.isInLargeView,
+ largeImageView.file.type == .video,
+ let currentURL = URL(string: largeImageView.file.path),
+ isEditableVideoURL(currentURL) {
+ if largeImageView.isInVideoCropSelectionMode {
+ largeImageView.confirmVideoCropSelection()
+ return
+ }
+ largeImageView.beginVideoCropSelectionMode()
+ return
+ } else {
+ urls = publicVar.selectedUrls().filter { isEditableVideoURL($0) }
+ }
+ guard !urls.isEmpty else {
+ showAlert(message: NSLocalizedString("Please select at least one video first.", comment: "请先选择至少一个视频。"))
+ return
+ }
+
+ guard let cropSize = promptVideoCropSize() else { return }
+ handleBatchCropVideos(urls, cropSize: cropSize)
+ }
+
+ func handleCropCurrentVideo(selection cropRect: VideoCropRect) {
+ guard publicVar.isInLargeView,
+ largeImageView.file.type == .video,
+ let currentURL = URL(string: largeImageView.file.path),
+ isEditableVideoURL(currentURL) else {
+ showAlert(message: NSLocalizedString("Please open a video first.", comment: "请先打开一个视频。"))
+ return
+ }
+
+ publicVar.isInFileOperation = true
+ coreAreaView.showOperationIndeterminate(
+ String(format: NSLocalizedString("Cropping %@", comment: "裁剪中 %@"), currentURL.lastPathComponent)
+ )
+
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ guard let self = self else { return }
+ let ok = self.cropVideoFile(currentURL, cropRect: cropRect)
+
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+ self.publicVar.isInFileOperation = false
+
+ if ok {
+ self.publicVar.fileChangedCount += 1
+ self.publicVar.filesForLocateAfterChange = [currentURL.absoluteString]
+ ThumbImageProcessor.clearCache()
+ LargeImageProcessor.clearCache()
+ self.coreAreaView.showOperationProgress(NSLocalizedString("Crop complete", comment: "裁剪完成"), progress: 1.0)
+ self.coreAreaView.hideOperationOverlay(delayed: 0.8)
+ self.changeLargeImage(firstShowThumb: false, resetSize: true, triggeredByLongPress: false, forceRefresh: true)
+ self.scheduledRefresh()
+ } else {
+ self.coreAreaView.hideOperationOverlay(delayed: 0.2)
+ showAlert(message: NSLocalizedString("Failed to crop video.", comment: "视频裁剪失败。"))
+ }
+ }
+ }
+ }
+
+ private func isRotatableMediaURL(_ url: URL) -> Bool {
+ if isReadOnlyVirtualFolderPath(url.absoluteString) || isVirtualArchiveEntryPath(url.absoluteString) {
+ return false
+ }
+ let ext = url.pathExtension.lowercased()
+ return isRotatableImageExtension(ext) || globalVar.HandledVideoExtensions.contains(ext)
+ }
+
+ private func isRotatableImageExtension(_ ext: String) -> Bool {
+ guard globalVar.HandledImageExtensions.contains(ext) else { return false }
+ return !["ai", "gif", "icns", "ico", "psd", "svg", "webp"].contains(ext)
+ }
+
+ private func isEditableVideoURL(_ url: URL) -> Bool {
+ !isReadOnlyVirtualFolderPath(url.absoluteString) &&
+ !isVirtualArchiveEntryPath(url.absoluteString) &&
+ globalVar.HandledVideoExtensions.contains(url.pathExtension.lowercased())
+ }
+
+ private func promptVideoCropSize() -> CGSize? {
+ let alert = NSAlert()
+ alert.messageText = NSLocalizedString("Crop Video Size", comment: "裁剪视频尺寸")
+ alert.informativeText = NSLocalizedString("Enter the target crop width and height in pixels. The video will be center-cropped and the original file will be replaced.", comment: "输入目标裁剪宽高(像素)。视频将居中裁剪并替换原文件。")
+ alert.alertStyle = .informational
+ alert.addButton(withTitle: NSLocalizedString("OK", comment: "确定"))
+ alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "取消"))
+
+ let widthField = NSTextField(frame: NSRect(x: 72, y: 34, width: 120, height: 24))
+ let heightField = NSTextField(frame: NSRect(x: 72, y: 0, width: 120, height: 24))
+ widthField.placeholderString = "1920"
+ heightField.placeholderString = "1080"
+
+ let widthLabel = NSTextField(labelWithString: NSLocalizedString("Width", comment: "宽度"))
+ widthLabel.frame = NSRect(x: 0, y: 36, width: 64, height: 20)
+ widthLabel.alignment = .right
+
+ let heightLabel = NSTextField(labelWithString: NSLocalizedString("Height", comment: "高度"))
+ heightLabel.frame = NSRect(x: 0, y: 2, width: 64, height: 20)
+ heightLabel.alignment = .right
+
+ let container = NSView(frame: NSRect(x: 0, y: 0, width: 210, height: 58))
+ container.addSubview(widthLabel)
+ container.addSubview(widthField)
+ container.addSubview(heightLabel)
+ container.addSubview(heightField)
+ alert.accessoryView = container
+
+ let storedKey = "videoCropSize"
+ if let stored = UserDefaults.standard.string(forKey: storedKey) {
+ let parts = stored.split(separator: "x")
+ if parts.count == 2 {
+ widthField.stringValue = String(parts[0])
+ heightField.stringValue = String(parts[1])
+ }
+ }
+
+ let previousKeyEventState = publicVar.isKeyEventEnabled
+ publicVar.isKeyEventEnabled = false
+ DispatchQueue.main.async {
+ widthField.becomeFirstResponder()
+ }
+ let response = alert.runModal()
+ publicVar.isKeyEventEnabled = previousKeyEventState
+
+ guard response == .alertFirstButtonReturn else { return nil }
+ let width = Int(widthField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
+ let height = Int(heightField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0
+ guard width > 0, height > 0 else {
+ showAlert(message: NSLocalizedString("Please enter a valid video crop size.", comment: "请输入有效的视频裁剪尺寸。"))
+ return nil
+ }
+
+ let evenWidth = width - (width % 2)
+ let evenHeight = height - (height % 2)
+ guard evenWidth > 0, evenHeight > 0 else {
+ showAlert(message: NSLocalizedString("Video crop size must be at least 2 pixels.", comment: "视频裁剪尺寸至少需要 2 像素。"))
+ return nil
+ }
+
+ UserDefaults.standard.set("\(evenWidth)x\(evenHeight)", forKey: storedKey)
+ return CGSize(width: evenWidth, height: evenHeight)
+ }
+
+ func handleBatchRotateSelectedMedia(_ rotation: BatchMediaRotation) {
+ var urls = publicVar.selectedUrls().filter { isRotatableMediaURL($0) }
+ if rotation == .restoreVideo {
+ urls = urls.filter { globalVar.HandledVideoExtensions.contains($0.pathExtension.lowercased()) }
+ }
+ guard !urls.isEmpty else {
+ if rotation == .restoreVideo {
+ showAlert(message: NSLocalizedString("Please select at least one video first.", comment: "请先选择至少一个视频。"))
+ } else {
+ showAlert(message: NSLocalizedString("Please select at least one image or video first.", comment: "请先选择至少一个图片或视频。"))
+ }
+ return
+ }
+
+ publicVar.isInFileOperation = true
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ guard let self = self else { return }
+ var failed: [URL] = []
+ let total = urls.count
+
+ for (index, url) in urls.enumerated() {
+ let startedRatio = Double(index) / Double(total)
+ DispatchQueue.main.async { [weak self] in
+ self?.coreAreaView.showOperationProgress(
+ String(
+ format: rotation == .restoreVideo
+ ? NSLocalizedString("Restoring %d/%d: %@", comment: "还原中 %d/%d: %@")
+ : NSLocalizedString("Rotating %d/%d: %@", comment: "旋转中 %d/%d: %@"),
+ index + 1, total, url.lastPathComponent
+ ),
+ progress: startedRatio
+ )
+ }
+
+ let ext = url.pathExtension.lowercased()
+ let ok: Bool
+ if self.isRotatableImageExtension(ext) {
+ ok = self.rotateImageFile(url, rotation: rotation)
+ } else {
+ ok = self.rotateVideoFile(url, rotation: rotation)
+ }
+ if !ok {
+ failed.append(url)
+ }
+
+ let finishedRatio = Double(index + 1) / Double(total)
+ DispatchQueue.main.async { [weak self] in
+ self?.coreAreaView.showOperationProgress(
+ String(
+ format: rotation == .restoreVideo
+ ? NSLocalizedString("Restoring... %d%%", comment: "还原中... %d%%")
+ : NSLocalizedString("Rotating... %d%%", comment: "旋转中... %d%%"),
+ Int(finishedRatio * 100)
+ ),
+ progress: finishedRatio
+ )
+ }
+ }
+
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+ self.publicVar.isInFileOperation = false
+ self.publicVar.fileChangedCount += total - failed.count
+ self.publicVar.filesForLocateAfterChange = urls.map(\.absoluteString)
+ ThumbImageProcessor.clearCache()
+ LargeImageProcessor.clearCache()
+
+ if failed.isEmpty {
+ self.coreAreaView.showOperationProgress(
+ rotation == .restoreVideo
+ ? NSLocalizedString("Restore complete", comment: "还原完成")
+ : NSLocalizedString("Rotation complete", comment: "旋转完成"),
+ progress: 1.0
+ )
+ self.coreAreaView.hideOperationOverlay(delayed: 0.8)
+ } else {
+ let preview = failed.prefix(3).map(\.lastPathComponent).joined(separator: ", ")
+ self.coreAreaView.showOperationToast(
+ String(format: NSLocalizedString("Rotation complete, failed: %d", comment: "旋转完成,失败:%d"), failed.count),
+ autoHide: 2.0
+ )
+ showAlert(message: String(format: NSLocalizedString("Failed to rotate some files: %@", comment: "部分文件旋转失败:%@"), preview))
+ }
+
+ if total - failed.count > 0 {
+ self.scheduledRefresh()
+ }
+ }
+ }
+ }
+
+ private func makeTemporarySiblingURL(for url: URL) -> URL {
+ url.deletingLastPathComponent()
+ .appendingPathComponent(".flowvision_rotate_\(UUID().uuidString)")
+ .appendingPathExtension(url.pathExtension)
+ }
+
+ private func replaceOriginalFile(at url: URL, with tempURL: URL) -> Bool {
+ do {
+ _ = try FileManager.default.replaceItemAt(url, withItemAt: tempURL, backupItemName: nil, options: [])
+ return true
+ } catch {
+ log("Failed to replace rotated file: \(error)", level: .error)
+ try? FileManager.default.removeItem(at: tempURL)
+ return false
+ }
+ }
+
+ private func handleBatchCropVideos(_ urls: [URL], cropSize: CGSize) {
+ publicVar.isInFileOperation = true
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ guard let self = self else { return }
+ var failed: [URL] = []
+ let total = urls.count
+
+ for (index, url) in urls.enumerated() {
+ let startedRatio = Double(index) / Double(total)
+ DispatchQueue.main.async { [weak self] in
+ self?.coreAreaView.showOperationProgress(
+ String(
+ format: NSLocalizedString("Cropping %d/%d: %@", comment: "裁剪中 %d/%d: %@"),
+ index + 1, total, url.lastPathComponent
+ ),
+ progress: startedRatio
+ )
+ }
+
+ if !self.cropVideoFile(url, cropSize: cropSize) {
+ failed.append(url)
+ }
+
+ let finishedRatio = Double(index + 1) / Double(total)
+ DispatchQueue.main.async { [weak self] in
+ self?.coreAreaView.showOperationProgress(
+ String(
+ format: NSLocalizedString("Cropping... %d%%", comment: "裁剪中... %d%%"),
+ Int(finishedRatio * 100)
+ ),
+ progress: finishedRatio
+ )
+ }
+ }
+
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+ self.publicVar.isInFileOperation = false
+ self.publicVar.fileChangedCount += total - failed.count
+ self.publicVar.filesForLocateAfterChange = urls.map(\.absoluteString)
+ ThumbImageProcessor.clearCache()
+ LargeImageProcessor.clearCache()
+
+ if failed.isEmpty {
+ self.coreAreaView.showOperationProgress(
+ NSLocalizedString("Crop complete", comment: "裁剪完成"),
+ progress: 1.0
+ )
+ self.coreAreaView.hideOperationOverlay(delayed: 0.8)
+ } else {
+ let preview = failed.prefix(3).map(\.lastPathComponent).joined(separator: ", ")
+ self.coreAreaView.showOperationToast(
+ String(format: NSLocalizedString("Crop complete, failed: %d", comment: "裁剪完成,失败:%d"), failed.count),
+ autoHide: 2.0
+ )
+ showAlert(message: String(format: NSLocalizedString("Failed to crop some videos: %@", comment: "部分视频裁剪失败:%@"), preview))
+ }
+
+ if total - failed.count > 0 {
+ self.scheduledRefresh()
+ }
+ }
+ }
+ }
+
+ private func cropVideoFile(_ url: URL, cropSize: CGSize) -> Bool {
+ let width = max(2, Int(cropSize.width) - (Int(cropSize.width) % 2))
+ let height = max(2, Int(cropSize.height) - (Int(cropSize.height) % 2))
+ let cropFilter = "crop=\(width):\(height):(iw-\(width))/2:(ih-\(height))/2,setsar=1"
+ return cropVideoFile(url, cropFilter: cropFilter)
+ }
+
+ private func cropVideoFile(_ url: URL, cropRect: VideoCropRect) -> Bool {
+ let cropFilter = "crop=\(cropRect.width):\(cropRect.height):\(cropRect.x):\(cropRect.y),setsar=1"
+ return cropVideoFile(url, cropFilter: cropFilter)
+ }
+
+ private func cropVideoFile(_ url: URL, cropFilter: String) -> Bool {
+ guard FFmpegKitWrapper.shared.getIfLoaded() else { return false }
+
+ let tempURL = makeTemporarySiblingURL(for: url)
+ try? FileManager.default.removeItem(at: tempURL)
+
+ let args = [
+ "-y",
+ "-i", url.path,
+ "-map", "0",
+ "-filter:v:0", cropFilter,
+ "-map_metadata", "0",
+ "-c:a", "copy",
+ "-c:s", "copy",
+ tempURL.path
+ ]
+
+ guard let session = FFmpegKitWrapper.shared.executeFFmpegCommand(args),
+ FFmpegKitWrapper.shared.isSuccess(FFmpegKitWrapper.shared.getReturnCode(from: session)) else {
+ try? FileManager.default.removeItem(at: tempURL)
+ return false
+ }
+
+ return replaceOriginalFile(at: url, with: tempURL)
+ }
+
+ private func rotateImageFile(_ url: URL, rotation: BatchMediaRotation) -> Bool {
+ guard let imageSource = CGImageSourceCreateWithURL(url as CFURL, nil),
+ CGImageSourceGetCount(imageSource) == 1,
+ let sourceType = CGImageSourceGetType(imageSource),
+ let image = NSImage(contentsOf: url),
+ let rotatedCGImage = image.rotated(by: rotation.imageDegrees).cgImage(forProposedRect: nil, context: nil, hints: nil) else {
+ return false
+ }
+
+ let tempURL = makeTemporarySiblingURL(for: url)
+ try? FileManager.default.removeItem(at: tempURL)
+ guard let destination = CGImageDestinationCreateWithURL(tempURL as CFURL, sourceType, 1, nil) else {
+ return false
+ }
+
+ let properties = (CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any]) ?? [:]
+ let mutableProperties = NSMutableDictionary(dictionary: properties)
+ mutableProperties[kCGImagePropertyOrientation] = 1
+ CGImageDestinationAddImage(destination, rotatedCGImage, mutableProperties)
+
+ guard CGImageDestinationFinalize(destination) else {
+ try? FileManager.default.removeItem(at: tempURL)
+ return false
+ }
+
+ return replaceOriginalFile(at: url, with: tempURL)
+ }
+
+ private func rotateVideoFile(_ url: URL, rotation: BatchMediaRotation) -> Bool {
+ guard FFmpegKitWrapper.shared.getIfLoaded() else { return false }
+
+ let tempURL = makeTemporarySiblingURL(for: url)
+ try? FileManager.default.removeItem(at: tempURL)
+
+ if rotation == .restoreVideo {
+ let restoreArgs = [
+ "-y",
+ "-i", url.path,
+ "-map", "0",
+ "-c", "copy",
+ "-map_metadata", "0",
+ "-metadata:s:v:0", "rotate=0",
+ tempURL.path
+ ]
+ guard let session = FFmpegKitWrapper.shared.executeFFmpegCommand(restoreArgs),
+ FFmpegKitWrapper.shared.isSuccess(FFmpegKitWrapper.shared.getReturnCode(from: session)) else {
+ try? FileManager.default.removeItem(at: tempURL)
+ return false
+ }
+ return replaceOriginalFile(at: url, with: tempURL)
+ }
+
+ // Fast path: for common mp4/mov containers, update rotate metadata without re-encoding.
+ let fastExts = Set(["mp4", "mov", "m4v"])
+ let ext = url.pathExtension.lowercased()
+ if fastExts.contains(ext) {
+ let rotateDegree: String
+ switch rotation {
+ case .clockwise90: rotateDegree = "90"
+ case .clockwise180: rotateDegree = "180"
+ case .counterclockwise90: rotateDegree = "270"
+ case .restoreVideo: rotateDegree = "0"
+ }
+ let copyArgs = [
+ "-y",
+ "-i", url.path,
+ "-map", "0",
+ "-c", "copy",
+ "-metadata:s:v:0", "rotate=\(rotateDegree)",
+ "-map_metadata", "0",
+ tempURL.path
+ ]
+ if let session = FFmpegKitWrapper.shared.executeFFmpegCommand(copyArgs),
+ FFmpegKitWrapper.shared.isSuccess(FFmpegKitWrapper.shared.getReturnCode(from: session)) {
+ return replaceOriginalFile(at: url, with: tempURL)
+ }
+ try? FileManager.default.removeItem(at: tempURL)
+ }
+
+ let args = [
+ "-y",
+ "-i", url.path,
+ "-map", "0",
+ "-filter:v:0", rotation.videoFilter,
+ "-map_metadata", "0",
+ "-c:a", "copy",
+ "-c:s", "copy",
+ tempURL.path
+ ]
+
+ guard let session = FFmpegKitWrapper.shared.executeFFmpegCommand(args),
+ FFmpegKitWrapper.shared.isSuccess(FFmpegKitWrapper.shared.getReturnCode(from: session)) else {
+ try? FileManager.default.removeItem(at: tempURL)
+ return false
+ }
+
+ return replaceOriginalFile(at: url, with: tempURL)
+ }
+
+ private func fileOperationUndoManager() -> UndoManager? {
+ view.window?.undoManager ?? NSApp.keyWindow?.undoManager ?? undoManager
+ }
+
+ private func remappedURLAfterRename(
+ _ url: URL,
+ mappings: [FileRenameMapping]
+ ) -> URL? {
+ let oldPath = url.standardizedFileURL.path
+ guard let mapping = mappings
+ .sorted(by: { $0.from.standardizedFileURL.path.count > $1.from.standardizedFileURL.path.count })
+ .first(where: { mapping in
+ let sourcePath = mapping.from.standardizedFileURL.path
+ return oldPath.lowercased() == sourcePath.lowercased() ||
+ oldPath.lowercased().hasPrefix(sourcePath.lowercased() + "/")
+ }) else {
+ return nil
+ }
+
+ let sourcePath = mapping.from.standardizedFileURL.path
+ let relativeSuffix = String(oldPath.dropFirst(sourcePath.count))
+ .trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ guard !relativeSuffix.isEmpty else { return mapping.to }
+ return URL(fileURLWithPath: mapping.to.standardizedFileURL.path, isDirectory: true)
+ .appendingPathComponent(relativeSuffix, isDirectory: url.hasDirectoryPath)
+ }
+
+ private func preserveCollectionScrollPosition(for folderPath: String) {
+ guard let clipView = collectionView.enclosingScrollView?.contentView else { return }
+ publicVar.collectionScrollRestoreAfterRefresh = (folderPath, clipView.bounds.origin)
+ }
+
+ private func preserveViewportAnchorForMove(_ sourceURLs: [URL], folderPath: String) {
+ publicVar.collectionViewportAnchorAfterRefresh = nil
+ guard let scrollView = collectionView.enclosingScrollView else { return }
+
+ let sourcePaths = Set(sourceURLs.map { $0.standardizedFileURL.path.lowercased() })
+ let selectedIndexes = collectionView.selectionIndexPaths.map(\.item).sorted()
+ guard !selectedIndexes.isEmpty else { return }
+ let displayedItemCount = collectionView.numberOfItems(inSection: 0)
+
+ fileDB.lock()
+ guard fileDB.curFolder == folderPath,
+ let files = fileDB.db[SortKeyDir(folderPath)]?.files else {
+ fileDB.unlock()
+ return
+ }
+ let loadedItemCount = min(files.count, displayedItemCount)
+ let removedIndexes = selectedIndexes.compactMap { index -> Int? in
+ guard index < loadedItemCount,
+ let path = files.elementSafe(atOffset: index)?.1.path,
+ let url = URL(string: path),
+ sourcePaths.contains(url.standardizedFileURL.path.lowercased()) else { return nil }
+ return index
+ }.sorted()
+ guard let firstRemoved = removedIndexes.first,
+ let lastRemoved = removedIndexes.last else {
+ fileDB.unlock()
+ return
+ }
+
+ let removedSet = Set(removedIndexes)
+ let successor = ((lastRemoved + 1).. Bool {
+ let candidatePath = candidate.standardizedFileURL.path
+ let ancestorPath = ancestor.standardizedFileURL.path
+ return candidatePath == ancestorPath || candidatePath.hasPrefix(ancestorPath + "/")
+ }
+
+ private func finishMoveOperation(
+ successfulDestURLs: [String],
+ movePairs: [(oldPath: String, newPath: String)],
+ failedCount: Int,
+ destinationURL: URL,
+ originalFolderPath: String,
+ pasteboard: NSPasteboard,
+ pasteboardChangeCount: Int
+ ) {
+ guard !successfulDestURLs.isEmpty else {
+ if failedCount > 0 {
+ coreAreaView.showOperationToast(
+ String(format: NSLocalizedString("Move failed for %d item(s)", comment: "有 %d 个项目移动失败"), failedCount),
+ autoHide: 2.0
+ )
+ }
+ return
+ }
+
+ triggerFinderSound()
+ let completedMappings = movePairs.compactMap { pair -> FileRenameMapping? in
+ guard !FileManager.default.fileExists(atPath: pair.oldPath) else { return nil }
+ return FileRenameMapping(
+ from: URL(fileURLWithPath: pair.oldPath),
+ to: URL(fileURLWithPath: pair.newPath)
+ )
+ }
+ let didMoveCurrentFolder = updateCurrentFolderPathAfterRename(completedMappings)
+ updateVideoPlaybackPathsAfterRename(completedMappings)
+
+ fileDB.lock()
+ let currentFolderPath = fileDB.curFolder
+ fileDB.unlock()
+ let currentFolderURL = URL(string: currentFolderPath)?.standardizedFileURL
+ let standardizedDestination = destinationURL.standardizedFileURL
+ let movedItemsAreVisible = currentFolderURL.map { currentURL in
+ standardizedDestination.path == currentURL.path ||
+ (publicVar.isRecursiveMode && isSameOrDescendant(standardizedDestination, of: currentURL))
+ } ?? false
+ let destinationFolderIsVisible = currentFolderURL.map { currentURL in
+ standardizedDestination.deletingLastPathComponent().path == currentURL.path
+ } ?? false
+
+ if didMoveCurrentFolder {
+ publicVar.filesForLocateAfterChange.removeAll()
+ publicVar.collectionViewportAnchorAfterRefresh = nil
+ } else if movedItemsAreVisible {
+ publicVar.filesForLocateAfterChange = successfulDestURLs
+ publicVar.filesForLocateAfterChangeTime = .now()
+ publicVar.collectionViewportAnchorAfterRefresh = nil
+ } else if currentFolderPath == originalFolderPath {
+ publicVar.filesForLocateAfterChange.removeAll()
+ preserveCollectionScrollPosition(for: originalFolderPath)
+ } else {
+ // The user navigated elsewhere while the background move was
+ // running. Do not apply the old folder's selection or scroll state.
+ publicVar.filesForLocateAfterChange.removeAll()
+ publicVar.collectionViewportAnchorAfterRefresh = nil
+ }
+
+ // A wrapper such as "Move to Downloads" may already have restored the
+ // user's clipboard while an asynchronous move was running.
+ if pasteboard === NSPasteboard.general,
+ pasteboard.changeCount == pasteboardChangeCount {
+ pasteboard.clearContents()
+ }
+
+ var shouldRefresh = didMoveCurrentFolder ||
+ currentFolderPath == originalFolderPath ||
+ movedItemsAreVisible ||
+ destinationFolderIsVisible
+ if shouldRefresh,
+ !didMoveCurrentFolder,
+ (publicVar.isRecursiveMode || isVirtualFolderPath(currentFolderPath)) {
+ fileDB.lock()
+ shouldRefresh = fileDB.db[SortKeyDir(fileDB.curFolder)]?.files.count ?? 0 <= RESET_VIEW_FILE_NUM_THRESHOLD
+ fileDB.unlock()
+ }
+ if shouldRefresh {
+ scheduledRefresh()
+ }
+
+ if failedCount > 0 {
+ coreAreaView.showOperationToast(
+ String(format: NSLocalizedString("Moved %d item(s), %d failed", comment: "已移动 %d 个项目,%d 个失败"), successfulDestURLs.count, failedCount),
+ autoHide: 2.0
+ )
+ }
+ }
+
+ private func executeUnconflictedMovesAsync(
+ _ plans: [(source: URL, destination: URL)],
+ destinationURL: URL,
+ originalFolderPath: String,
+ pasteboard: NSPasteboard,
+ checkConflictsBeforeMoving: Bool = false
+ ) {
+ let pasteboardChangeCount = pasteboard.changeCount
+ publicVar.isInFileOperation = true
+ coreAreaView.showOperationIndeterminate(
+ String(format: NSLocalizedString("Moving %d item(s)…", comment: "正在移动 %d 个项目…"), plans.count)
+ )
+
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ guard let self else { return }
+ if checkConflictsBeforeMoving {
+ let existingTargetPaths = Set(plans.compactMap { plan -> String? in
+ FileManager.default.fileExists(atPath: plan.destination.path)
+ ? plan.destination.standardizedFileURL.path.lowercased()
+ : nil
+ })
+ if !existingTargetPaths.isEmpty {
+ DispatchQueue.main.async { [weak self] in
+ guard let self else { return }
+ self.publicVar.isInFileOperation = false
+ self.coreAreaView.hideOperationOverlay(delayed: 0)
+
+ let snapshotPasteboard = NSPasteboard(
+ name: NSPasteboard.Name("FlowVision.Move.\(UUID().uuidString)")
+ )
+ snapshotPasteboard.clearContents()
+ snapshotPasteboard.writeObjects(plans.map(\.source) as [NSPasteboardWriting])
+ self.handleMove(
+ targetURL: destinationURL,
+ pasteboard: snapshotPasteboard,
+ allowBackgroundPreflight: false,
+ knownExistingTargetPaths: existingTargetPaths
+ )
+ }
+ return
+ }
+ }
+
+ var successfulURLs: [String] = []
+ var movePairs: [(oldPath: String, newPath: String)] = []
+ var failedCount = 0
+
+ for plan in plans {
+ do {
+ try FileManager.default.moveItem(at: plan.source, to: plan.destination)
+ successfulURLs.append(plan.destination.absoluteString)
+ movePairs.append((oldPath: plan.source.path, newPath: plan.destination.path))
+ } catch {
+ failedCount += 1
+ log("Failed to move \(plan.source): \(error)", level: .error)
+ }
+ }
+ if !movePairs.isEmpty {
+ EnhancedIndex.handleFilesMoved(movePairs)
+ }
+
+ DispatchQueue.main.async { [weak self] in
+ guard let self else { return }
+ defer { self.publicVar.isInFileOperation = false }
+ self.publicVar.fileChangedCount += successfulURLs.count
+ self.finishMoveOperation(
+ successfulDestURLs: successfulURLs,
+ movePairs: movePairs,
+ failedCount: failedCount,
+ destinationURL: destinationURL,
+ originalFolderPath: originalFolderPath,
+ pasteboard: pasteboard,
+ pasteboardChangeCount: pasteboardChangeCount
+ )
+ if failedCount == 0 {
+ self.coreAreaView.showOperationProgress(
+ NSLocalizedString("Move complete", comment: "移动完成"),
+ progress: 1.0
+ )
+ }
+ self.coreAreaView.hideOperationOverlay(delayed: 0.8)
+ }
+ }
+ }
+
+ /// Keeps active video players associated with the renamed file so a fallback
+ /// collection refresh does not reload playback from the beginning.
+ private func updateVideoPlaybackPathsAfterRename(_ mappings: [FileRenameMapping]) {
+ for case let item as CustomCollectionViewItem in collectionView.visibleItems() {
+ guard let playingURL = item.currentPlayingURL,
+ let newURL = remappedURLAfterRename(playingURL, mappings: mappings) else { continue }
+ item.currentPlayingURL = newURL
+ }
+
+ var didUpdateLargeImagePath = false
+ if let fileURL = URL(string: largeImageView.file.path),
+ let newURL = remappedURLAfterRename(fileURL, mappings: mappings) {
+ largeImageView.file.path = newURL.absoluteString
+ largeImageView.file.ext = newURL.pathExtension.lowercased()
+ didUpdateLargeImagePath = true
+ }
+ if let playingURL = largeImageView.currentPlayingURL,
+ let newURL = remappedURLAfterRename(playingURL, mappings: mappings) {
+ largeImageView.currentPlayingURL = newURL
+ }
+ if let restoreURL = largeImageView.restorePlayURL,
+ let newURL = remappedURLAfterRename(restoreURL, mappings: mappings) {
+ largeImageView.restorePlayURL = newURL
+ }
+ if let finderURL = URL(string: publicVar.openFromFinderPath),
+ let newURL = remappedURLAfterRename(finderURL, mappings: mappings) {
+ publicVar.openFromFinderPath = newURL.absoluteString
+ }
+ if didUpdateLargeImagePath {
+ setWindowTitleOfLargeImage(file: largeImageView.file)
+ }
+ }
+
+ /// Keeps the active browser pointed at the same directory when that directory,
+ /// or one of its ancestors, is renamed.
+ private func updateCurrentFolderPathAfterRename(_ mappings: [FileRenameMapping]) -> Bool {
+ fileDB.lock()
+ let oldFolderPath = fileDB.curFolder
+ fileDB.unlock()
+
+ guard let oldFolderURL = URL(string: oldFolderPath), oldFolderURL.isFileURL else {
+ return false
+ }
+
+ let oldPath = oldFolderURL.standardizedFileURL.path
+ let matchingMapping = mappings
+ .sorted { $0.from.standardizedFileURL.path.count > $1.from.standardizedFileURL.path.count }
+ .first { mapping in
+ let sourcePath = mapping.from.standardizedFileURL.path
+ return oldPath.lowercased() == sourcePath.lowercased() ||
+ oldPath.lowercased().hasPrefix(sourcePath.lowercased() + "/")
+ }
+ guard let matchingMapping else { return false }
+
+ let sourcePath = matchingMapping.from.standardizedFileURL.path
+ let relativeSuffix = String(oldPath.dropFirst(sourcePath.count))
+ .trimmingCharacters(in: CharacterSet(charactersIn: "/"))
+ let targetFolderURL = URL(
+ fileURLWithPath: matchingMapping.to.standardizedFileURL.path,
+ isDirectory: true
+ )
+ let newFolderURL = relativeSuffix.isEmpty
+ ? targetFolderURL
+ : targetFolderURL.appendingPathComponent(relativeSuffix, isDirectory: true)
+ let newFolderPath = newFolderURL.absoluteString
+
+ fileDB.lock()
+ guard fileDB.curFolder == oldFolderPath else {
+ fileDB.unlock()
+ return false
+ }
+ fileDB.curFolder = newFolderPath
+ fileDB.unlock()
+
+ preserveCollectionScrollPosition(for: newFolderPath)
+ publicVar.filesForLocateAfterChange.removeAll()
+ return true
+ }
+
+ /// Updates rename-only state without rebuilding collection items or media players.
+ /// Returns false when the collection is not fully loaded and a normal refresh is required.
+ private func applyRenameMappingsInPlace(
+ _ mappings: [FileRenameMapping],
+ folderPath: String
+ ) -> Bool {
+ guard !mappings.isEmpty else { return true }
+ let mappingBySourcePath = Dictionary(
+ uniqueKeysWithValues: mappings.map { ($0.from.path.lowercased(), $0) }
+ )
+ let largeViewOldPath = largeImageView.file.path
+
+ fileDB.lock()
+ guard fileDB.curFolder == folderPath,
+ let dirModel = fileDB.db[SortKeyDir(folderPath)] else {
+ fileDB.unlock()
+ return false
+ }
+
+ let oldEntries = getMapKeysFile(dirModel.files)
+ guard collectionView.numberOfItems(inSection: 0) == oldEntries.count else {
+ fileDB.unlock()
+ return false
+ }
+
+ let availablePaths = Set(oldEntries.compactMap { entry in
+ URL(string: entry.1.path)?.path.lowercased()
+ })
+ guard Set(mappingBySourcePath.keys).isSubset(of: availablePaths) else {
+ fileDB.unlock()
+ return false
+ }
+
+ let oldModels = oldEntries.map(\.1)
+ let selectedModelIDs = Set(collectionView.selectionIndexPaths.compactMap { indexPath -> ObjectIdentifier? in
+ guard oldModels.indices.contains(indexPath.item) else { return nil }
+ return ObjectIdentifier(oldModels[indexPath.item])
+ })
+ var rebuiltFiles = Map()
+ for (oldKey, model) in oldEntries {
+ guard let modelURL = URL(string: model.path),
+ let mapping = mappingBySourcePath[modelURL.path.lowercased()] else {
+ rebuiltFiles[oldKey] = model
+ continue
+ }
+
+ let newPath = mapping.to.absoluteString
+ model.path = newPath
+ model.ext = mapping.to.pathExtension.lowercased()
+
+ guard let newKey = oldKey.copy() as? SortKeyFile else {
+ fileDB.unlock()
+ return false
+ }
+ newKey.path = newPath
+ newKey.pathCmp = newPath.lowercased()
+ newKey.exifDate = oldKey.exifDate
+ newKey.exifPixel = oldKey.exifPixel
+ newKey.rating = oldKey.rating
+ newKey.tag = oldKey.tag
+ newKey.isTagLoaded = oldKey.isTagLoaded
+ rebuiltFiles[newKey] = model
+ }
+ dirModel.files = rebuiltFiles
+ let newModels = getMapKeysFile(rebuiltFiles).map(\.1)
+ fileDB.unlock()
+
+ let oldIndexByModel = Dictionary(
+ uniqueKeysWithValues: oldModels.enumerated().map { (ObjectIdentifier($0.element), $0.offset) }
+ )
+ let newIndexByModel = Dictionary(
+ uniqueKeysWithValues: newModels.enumerated().map { (ObjectIdentifier($0.element), $0.offset) }
+ )
+ guard Set(oldIndexByModel.keys) == Set(newIndexByModel.keys) else { return false }
+
+ for case let item as CustomCollectionViewItem in collectionView.visibleItems() {
+ guard let oldURL = item.imageViewObj.url,
+ let mapping = mappingBySourcePath[oldURL.path.lowercased()] else { continue }
+ let newURL = mapping.to
+ item.imageViewObj.url = newURL
+ if item.currentPlayingURL?.path.lowercased() == oldURL.path.lowercased() {
+ item.currentPlayingURL = newURL
+ }
+ item.imageNameField.stringValue = publicVar.profile.isShowThumbnailFilename ? newURL.lastPathComponent : ""
+ item.setTooltip()
+ }
+
+ let moves = oldIndexByModel.compactMap { modelID, oldIndex -> (IndexPath, IndexPath)? in
+ guard let newIndex = newIndexByModel[modelID], newIndex != oldIndex else { return nil }
+ return (IndexPath(item: oldIndex, section: 0), IndexPath(item: newIndex, section: 0))
+ }
+ if !moves.isEmpty {
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = 0
+ collectionView.performBatchUpdates {
+ for move in moves {
+ collectionView.moveItem(at: move.0, to: move.1)
+ }
+ }
+ }
+ }
+ if !selectedModelIDs.isEmpty {
+ collectionView.selectionIndexPaths = Set(newModels.enumerated().compactMap { index, model in
+ selectedModelIDs.contains(ObjectIdentifier(model))
+ ? IndexPath(item: index, section: 0)
+ : nil
+ })
+ }
+
+ if let oldLargeURL = URL(string: largeViewOldPath),
+ let mapping = mappingBySourcePath[oldLargeURL.path.lowercased()] {
+ let newURL = mapping.to
+ largeImageView.file.path = newURL.absoluteString
+ largeImageView.file.ext = newURL.pathExtension.lowercased()
+ if largeImageView.currentPlayingURL?.path.lowercased() == oldLargeURL.path.lowercased() {
+ largeImageView.currentPlayingURL = newURL
+ }
+ if largeImageView.restorePlayURL?.path.lowercased() == oldLargeURL.path.lowercased() {
+ largeImageView.restorePlayURL = newURL
+ }
+ if let openURL = URL(string: publicVar.openFromFinderPath),
+ openURL.path.lowercased() == oldLargeURL.path.lowercased() {
+ publicVar.openFromFinderPath = newURL.absoluteString
+ }
+ setWindowTitleOfLargeImage(file: largeImageView.file)
+ }
+
+ return true
+ }
+
+ @discardableResult
+ private func executeFileRenameMappings(
+ _ mappings: [FileRenameMapping],
+ actionName: String,
+ registerUndo: Bool = true,
+ locateTargets: [URL]? = nil,
+ inPlaceFolderPath: String? = nil
+ ) -> Bool {
+ guard !mappings.isEmpty else { return true }
+
+ let fileManager = FileManager.default
+ let sourcePathSet = Set(mappings.map { $0.from.path.lowercased() })
+
+ for mapping in mappings {
+ guard fileManager.fileExists(atPath: mapping.from.path) else {
+ log("Rename source missing: \(mapping.from.path)", level: .error)
+ return false
+ }
+
+ let targetPath = mapping.to.path.lowercased()
+ if mapping.from.path == mapping.to.path {
+ continue
+ }
+
+ if fileManager.fileExists(atPath: mapping.to.path) && !sourcePathSet.contains(targetPath) {
+ showAlert(message: String(format: NSLocalizedString("无法完成重命名,目标已存在:%@", comment: "rename undo conflict"), mapping.to.lastPathComponent))
+ return false
+ }
+ }
+
+ publicVar.isInFileOperation = true
+ defer { publicVar.isInFileOperation = false }
+
+ var pendingMoves: [PendingTempRename] = []
+
+ for mapping in mappings {
+ if mapping.from.path == mapping.to.path {
+ continue
+ }
+
+ let tempURL = mapping.from.deletingLastPathComponent().appendingPathComponent("temp_rename_\(UUID().uuidString)")
+ do {
+ try fileManager.moveItem(at: mapping.from, to: tempURL)
+ pendingMoves.append(PendingTempRename(tempURL: tempURL, targetURL: mapping.to))
+ } catch {
+ for pending in pendingMoves.reversed() {
+ try? fileManager.moveItem(at: pending.tempURL, to: mappings.first(where: { $0.to == pending.targetURL })?.from ?? pending.targetURL)
+ }
+ log("Failed to create temp rename path: \(error)", level: .error)
+ showAlert(message: String(format: NSLocalizedString("重命名失败:%@", comment: "rename failed"), error.localizedDescription))
+ return false
+ }
+ }
+
+ var appliedMoves: [FileRenameMapping] = []
+ for pending in pendingMoves {
+ do {
+ try fileManager.moveItem(at: pending.tempURL, to: pending.targetURL)
+ publicVar.fileChangedCount += 1
+ if let source = mappings.first(where: { $0.to == pending.targetURL })?.from {
+ appliedMoves.append(FileRenameMapping(from: source, to: pending.targetURL))
+ }
+ } catch {
+ for applied in appliedMoves.reversed() {
+ try? fileManager.moveItem(at: applied.to, to: applied.from)
+ }
+ for remaining in pendingMoves where fileManager.fileExists(atPath: remaining.tempURL.path) {
+ if let original = mappings.first(where: { $0.to == remaining.targetURL })?.from {
+ try? fileManager.moveItem(at: remaining.tempURL, to: original)
+ }
+ }
+ log("Failed to complete rename: \(error)", level: .error)
+ showAlert(message: String(format: NSLocalizedString("重命名失败:%@", comment: "rename failed"), error.localizedDescription))
+ return false
+ }
+ }
+
+ guard !appliedMoves.isEmpty else { return true }
+
+ EnhancedIndex.handleFilesMoved(appliedMoves.map { (oldPath: $0.from.path, newPath: $0.to.path) })
+ let didRenameCurrentFolder = updateCurrentFolderPathAfterRename(appliedMoves)
+ let didUpdateInPlace = inPlaceFolderPath.map {
+ applyRenameMappingsInPlace(appliedMoves, folderPath: $0)
+ } ?? false
+ updateVideoPlaybackPathsAfterRename(appliedMoves)
+ if didUpdateInPlace || didRenameCurrentFolder {
+ publicVar.filesForLocateAfterChange.removeAll()
+ if !didRenameCurrentFolder {
+ publicVar.collectionScrollRestoreAfterRefresh = nil
+ }
+ } else {
+ publicVar.filesForLocateAfterChange = (locateTargets ?? appliedMoves.map(\.to)).map(\.absoluteString)
+ }
+
+ if registerUndo, let undoManager = fileOperationUndoManager() {
+ let inverseMappings = appliedMoves.map { FileRenameMapping(from: $0.to, to: $0.from) }
+ undoManager.registerUndo(withTarget: self) { target in
+ _ = target.executeFileRenameMappings(
+ inverseMappings,
+ actionName: actionName,
+ registerUndo: true,
+ locateTargets: appliedMoves.map(\.from),
+ inPlaceFolderPath: inPlaceFolderPath
+ )
+ }
+ undoManager.setActionName(actionName)
+ }
+
+ var ifRefresh = true
+ fileDB.lock()
+ let curFolder = fileDB.curFolder
+ fileDB.unlock()
+ if publicVar.isRecursiveMode || isVirtualFolderPath(curFolder) {
+ fileDB.lock()
+ ifRefresh = fileDB.db[SortKeyDir(fileDB.curFolder)]?.files.count ?? 0 <= RESET_VIEW_FILE_NUM_THRESHOLD
+ fileDB.unlock()
+ }
+ if didRenameCurrentFolder || (!didUpdateInPlace && ifRefresh) {
+ scheduledRefresh()
+ }
+
+ return true
+ }
+
+ private func executeFileRenameMappingsAsync(
+ _ mappings: [FileRenameMapping],
+ actionName: String,
+ locateTargets: [URL]? = nil,
+ inPlaceFolderPath: String? = nil
+ ) {
+ guard !mappings.isEmpty else {
+ publicVar.isInFileOperation = false
+ coreAreaView.hideOperationOverlay(delayed: 0.2)
+ return
+ }
+
+ publicVar.isInFileOperation = true
+ coreAreaView.showOperationIndeterminate(NSLocalizedString("Preparing rename…", comment: "正在准备重命名…"))
+
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ guard let self = self else { return }
+
+ let fileManager = FileManager.default
+ let sourcePathSet = Set(mappings.map { $0.from.path.lowercased() })
+ let sourceByTargetPath = Dictionary(
+ uniqueKeysWithValues: mappings.map { ($0.to.path, $0.from) }
+ )
+ var failureMessage: String?
+
+ for mapping in mappings {
+ guard fileManager.fileExists(atPath: mapping.from.path) else {
+ failureMessage = "重命名源文件不存在:\(mapping.from.lastPathComponent)"
+ break
+ }
+ if mapping.from.path == mapping.to.path { continue }
+ let targetPath = mapping.to.path.lowercased()
+ if fileManager.fileExists(atPath: mapping.to.path) && !sourcePathSet.contains(targetPath) {
+ failureMessage = "无法完成重命名,目标已存在:\(mapping.to.lastPathComponent)"
+ break
+ }
+ }
+
+ let changedMappings = mappings.filter { $0.from.path != $0.to.path }
+ var pendingMoves: [PendingTempRename] = []
+ var appliedMoves: [FileRenameMapping] = []
+ let total = max(changedMappings.count, 1)
+
+ if failureMessage == nil {
+ for (index, mapping) in changedMappings.enumerated() {
+ if index == 0 || index == changedMappings.count - 1 || index % max(1, total / 100) == 0 {
+ DispatchQueue.main.async { [weak self] in
+ self?.coreAreaView.showOperationProgress(
+ "正在准备重命名 \(index + 1)/\(changedMappings.count)",
+ progress: Double(index) / Double(total) * 0.5
+ )
+ }
+ }
+
+ let tempURL = mapping.from.deletingLastPathComponent().appendingPathComponent("temp_rename_\(UUID().uuidString)")
+ do {
+ try fileManager.moveItem(at: mapping.from, to: tempURL)
+ pendingMoves.append(PendingTempRename(tempURL: tempURL, targetURL: mapping.to))
+ } catch {
+ failureMessage = error.localizedDescription
+ for pending in pendingMoves.reversed() {
+ if let originalURL = sourceByTargetPath[pending.targetURL.path] {
+ try? fileManager.moveItem(at: pending.tempURL, to: originalURL)
+ }
+ }
+ pendingMoves.removeAll()
+ break
+ }
+ }
+ }
+
+ if failureMessage == nil {
+ for (index, pending) in pendingMoves.enumerated() {
+ if index == 0 || index == pendingMoves.count - 1 || index % max(1, total / 100) == 0 {
+ DispatchQueue.main.async { [weak self] in
+ self?.coreAreaView.showOperationProgress(
+ "正在重命名 \(index + 1)/\(pendingMoves.count)",
+ progress: 0.5 + Double(index) / Double(total) * 0.5
+ )
+ }
+ }
+
+ do {
+ try fileManager.moveItem(at: pending.tempURL, to: pending.targetURL)
+ if let sourceURL = sourceByTargetPath[pending.targetURL.path] {
+ appliedMoves.append(FileRenameMapping(from: sourceURL, to: pending.targetURL))
+ }
+ } catch {
+ failureMessage = error.localizedDescription
+ for applied in appliedMoves.reversed() {
+ try? fileManager.moveItem(at: applied.to, to: applied.from)
+ }
+ for remaining in pendingMoves where fileManager.fileExists(atPath: remaining.tempURL.path) {
+ if let originalURL = sourceByTargetPath[remaining.targetURL.path] {
+ try? fileManager.moveItem(at: remaining.tempURL, to: originalURL)
+ }
+ }
+ appliedMoves.removeAll()
+ break
+ }
+ }
+ }
+
+ if failureMessage == nil, !appliedMoves.isEmpty {
+ EnhancedIndex.handleFilesMoved(
+ appliedMoves.map { (oldPath: $0.from.path, newPath: $0.to.path) }
+ )
+ }
+
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+ defer { self.publicVar.isInFileOperation = false }
+
+ if let failureMessage = failureMessage {
+ self.publicVar.collectionScrollRestoreAfterRefresh = nil
+ self.coreAreaView.hideOperationOverlay(delayed: 0.2)
+ showAlert(message: "重命名失败:\(failureMessage)")
+ return
+ }
+
+ guard !appliedMoves.isEmpty else {
+ self.publicVar.collectionScrollRestoreAfterRefresh = nil
+ self.coreAreaView.showOperationToast("文件名无需更改", autoHide: 1.5)
+ return
+ }
+
+ self.publicVar.fileChangedCount += appliedMoves.count
+ let didRenameCurrentFolder = self.updateCurrentFolderPathAfterRename(appliedMoves)
+ let didUpdateInPlace = inPlaceFolderPath.map {
+ self.applyRenameMappingsInPlace(appliedMoves, folderPath: $0)
+ } ?? false
+ self.updateVideoPlaybackPathsAfterRename(appliedMoves)
+ if didUpdateInPlace || didRenameCurrentFolder {
+ self.publicVar.filesForLocateAfterChange.removeAll()
+ if !didRenameCurrentFolder {
+ self.publicVar.collectionScrollRestoreAfterRefresh = nil
+ }
+ } else {
+ self.publicVar.filesForLocateAfterChange = (locateTargets ?? appliedMoves.map(\.to)).map(\.absoluteString)
+ }
+
+ if let undoManager = self.fileOperationUndoManager() {
+ let inverseMappings = appliedMoves.map { FileRenameMapping(from: $0.to, to: $0.from) }
+ undoManager.registerUndo(withTarget: self) { target in
+ _ = target.executeFileRenameMappings(
+ inverseMappings,
+ actionName: actionName,
+ registerUndo: true,
+ locateTargets: appliedMoves.map(\.from),
+ inPlaceFolderPath: inPlaceFolderPath
+ )
+ }
+ undoManager.setActionName(actionName)
+ }
+
+ var shouldRefresh = true
+ self.fileDB.lock()
+ let currentFolder = self.fileDB.curFolder
+ if self.publicVar.isRecursiveMode || isVirtualFolderPath(currentFolder) {
+ shouldRefresh = self.fileDB.db[SortKeyDir(currentFolder)]?.files.count ?? 0 <= RESET_VIEW_FILE_NUM_THRESHOLD
+ }
+ self.fileDB.unlock()
+ if didRenameCurrentFolder || (!didUpdateInPlace && shouldRefresh) {
+ self.scheduledRefresh()
+ }
+
+ self.coreAreaView.showOperationProgress("重命名完成", progress: 1.0)
+ self.coreAreaView.hideOperationOverlay(delayed: 0.8)
+ }
+ }
+ }
+
@discardableResult
func handleFilePromiseDrop(targetURL: URL, pasteboard: NSPasteboard) -> Bool {
guard let receivers = pasteboard.readObjects(forClasses: [NSFilePromiseReceiver.self], options: nil) as? [NSFilePromiseReceiver],
!receivers.isEmpty else {
return false
}
-
+
let fileManager = FileManager.default
let tempRoot = fileManager.temporaryDirectory.appendingPathComponent("FlowVisionPromisedFiles-\(UUID().uuidString)", isDirectory: true)
do {
@@ -25,10 +1368,10 @@ extension ViewController {
log("Failed to create temp folder for promised files: \(error)", level: .error)
return false
}
-
+
var pendingCount = receivers.count
var receivedURLs: [URL] = []
-
+
for receiver in receivers {
receiver.receivePromisedFiles(atDestination: tempRoot, options: [:], operationQueue: .main) { [weak self] fileURL, error in
if let error = error {
@@ -36,15 +1379,15 @@ extension ViewController {
} else {
receivedURLs.append(fileURL)
}
-
+
pendingCount -= 1
if pendingCount == 0 {
defer { try? fileManager.removeItem(at: tempRoot) }
-
+
guard let self = self, !receivedURLs.isEmpty else {
return
}
-
+
let tempPasteboard = NSPasteboard(name: NSPasteboard.Name(UUID().uuidString))
tempPasteboard.clearContents()
tempPasteboard.writeObjects(receivedURLs as [NSURL])
@@ -53,14 +1396,14 @@ extension ViewController {
}
}
}
-
+
return true
}
-
+
func getUniqueDestinationURL(for url: URL, isInPlace: Bool = false) -> URL {
var newURL = url
var counter = 1
-
+
while FileManager.default.fileExists(atPath: newURL.path) {
let baseName = url.deletingPathExtension().lastPathComponent
let extensionName = url.pathExtension
@@ -70,15 +1413,15 @@ extension ViewController {
duplicateName = NSLocalizedString("copy-lowercase", comment: "copy(首字母小写)")
newName = "\(baseName)_\(duplicateName)\(counter > 1 ? "\(counter)" : "")"
}
-
-
+
+
newURL = url.deletingLastPathComponent().appendingPathComponent(newName).appendingPathExtension(extensionName)
counter += 1
}
-
+
return newURL
}
-
+
func handleNewFolder(targetURL: URL? = nil) -> (Bool,URL?) {
let alert = NSAlert()
alert.messageText = NSLocalizedString("New Folder", comment: "新建文件夹")
@@ -87,7 +1430,7 @@ extension ViewController {
// 设置系统通知图标
// Set system notification icon
alert.icon = NSImage(named: NSImage.infoName)
-
+
// 添加一个文本输入框
// Add a text input field
let inputTextField = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 24))
@@ -97,10 +1440,10 @@ extension ViewController {
textFieldCell.isScrollable = true
}
alert.accessoryView = inputTextField
-
+
alert.addButton(withTitle: NSLocalizedString("OK", comment: "确定"))
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "取消"))
-
+
let StoreIsKeyEventEnabled = publicVar.isKeyEventEnabled
publicVar.isKeyEventEnabled=false
DispatchQueue.main.async {
@@ -108,21 +1451,21 @@ extension ViewController {
}
let response = alert.runModal()
publicVar.isKeyEventEnabled=StoreIsKeyEventEnabled
-
+
if response == .alertFirstButtonReturn {
let folderName = inputTextField.stringValue
-
+
if !folderName.isEmpty {
fileDB.lock()
let curFolder = fileDB.curFolder
fileDB.unlock()
-
+
var destinationURL = URL(string: curFolder)
if targetURL != nil {destinationURL=targetURL}
guard let destinationURL=destinationURL else {return (false,nil)}
-
+
let newFolderURL = destinationURL.appendingPathComponent(folderName)
-
+
// 检查是否存在同名文件
// Check if file with same name exists
if FileManager.default.fileExists(atPath: newFolderURL.path) {
@@ -134,7 +1477,7 @@ extension ViewController {
// 文件更改计数
// File change count
publicVar.fileChangedCount += 1
-
+
try FileManager.default.createDirectory(at: newFolderURL, withIntermediateDirectories: true, attributes: nil)
log("Successfully created folder: \(newFolderURL.path)")
publicVar.filesForLocateAfterChange = [newFolderURL.absoluteString]
@@ -158,7 +1501,7 @@ extension ViewController {
// 设置系统通知图标
// Set system notification icon
alert.icon = NSImage(named: NSImage.infoName)
-
+
// 添加一个文本输入框
// Add a text input field
let inputTextField = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 24))
@@ -168,10 +1511,10 @@ extension ViewController {
textFieldCell.isScrollable = true
}
alert.accessoryView = inputTextField
-
+
alert.addButton(withTitle: NSLocalizedString("OK", comment: "确定"))
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "取消"))
-
+
let StoreIsKeyEventEnabled = publicVar.isKeyEventEnabled
publicVar.isKeyEventEnabled=false
DispatchQueue.main.async {
@@ -179,27 +1522,27 @@ extension ViewController {
}
let response = alert.runModal()
publicVar.isKeyEventEnabled=StoreIsKeyEventEnabled
-
+
if response == .alertFirstButtonReturn {
var fileName = inputTextField.stringValue
-
+
if !fileName.isEmpty {
// 如果用户没有输入扩展名,则加.txt后缀
// If user didn't enter extension, add .txt suffix
if !fileName.contains(".") {
fileName += ".txt"
}
-
+
fileDB.lock()
let curFolder = fileDB.curFolder
fileDB.unlock()
-
+
var destinationURL = URL(string: curFolder)
if targetURL != nil {destinationURL=targetURL}
guard let destinationURL=destinationURL else {return (false,nil)}
-
+
let newFileURL = destinationURL.appendingPathComponent(fileName)
-
+
// 检查是否存在同名文件
// Check if file with same name exists
if FileManager.default.fileExists(atPath: newFileURL.path) {
@@ -211,11 +1554,11 @@ extension ViewController {
// 创建空文本文件
// Create empty text file
try "".write(to: newFileURL, atomically: true, encoding: .utf8)
-
+
// 文件更改计数
// File change count
publicVar.fileChangedCount += 1
-
+
log("Successfully created text file: \(newFileURL.path)")
publicVar.filesForLocateAfterChange = [newFileURL.absoluteString]
publicVar.filesForLocateAfterChangeTime = .now()
@@ -226,124 +1569,769 @@ extension ViewController {
}
}
}
- return (false,nil)
+ return (false,nil)
+ }
+
+ func handleNewFolderWithSelection() {
+ var urls = publicVar.selectedUrls()
+ if urls.isEmpty {return}
+
+ let (ifSuccess,newFolderURL) = handleNewFolder()
+
+ if ifSuccess {
+ // 备份剪贴板内容
+ // Backup pasteboard content
+ let backupItems = backupPasteboard()
+
+ handleCopy()
+ handleMove(targetURL: newFolderURL)
+
+ if let newFolderURL = newFolderURL {
+ publicVar.filesForLocateAfterChange = [newFolderURL.absoluteString]
+ publicVar.filesForLocateAfterChangeTime = .now()
+ }
+
+ // 还原剪贴板内容
+ // Restore pasteboard content
+ restorePasteboard(items: backupItems)
+ }
+
+ }
+
+// // 备份剪贴板内容的函数
+// func backupPasteboard() -> [NSPasteboard.PasteboardType: Any] {
+// let pasteboard = NSPasteboard.general
+// var backupItems = [NSPasteboard.PasteboardType: Any]()
+//
+// for type in pasteboard.types ?? [] {
+// if let item = pasteboard.data(forType: type) {
+// backupItems[type] = item
+// }
+// }
+//
+// return backupItems
+// }
+//
+// // 还原剪贴板内容的函数
+// func restorePasteboard(items: [NSPasteboard.PasteboardType: Any]) {
+// let pasteboard = NSPasteboard.general
+// pasteboard.clearContents()
+//
+// for (type, item) in items {
+// if let data = item as? Data {
+// pasteboard.setData(data, forType: type)
+// }
+// }
+// }
+
+ // 备份剪贴板内容的函数
+ // Function to backup pasteboard content
+ func backupPasteboard() -> [[String: Data]] {
+ let pasteboard = NSPasteboard.general
+ var backupItems = [[String: Data]]()
+
+ for item in pasteboard.pasteboardItems ?? [] {
+ var backupItem = [String: Data]()
+ for type in item.types {
+ if let data = item.data(forType: type) {
+ backupItem[type.rawValue] = data
+ }
+ }
+ backupItems.append(backupItem)
+ }
+
+ return backupItems
+ }
+
+ // 还原剪贴板内容的函数
+ // Function to restore pasteboard content
+ func restorePasteboard(items: [[String: Data]]) {
+ let pasteboard = NSPasteboard.general
+ pasteboard.clearContents()
+
+ for itemData in items {
+ let newItem = NSPasteboardItem()
+ for (type, data) in itemData {
+ newItem.setData(data, forType: NSPasteboard.PasteboardType(rawValue: type))
+ }
+ pasteboard.writeObjects([newItem])
+ }
+ }
+
+ func handleCopy() {
+ let pasteboard = NSPasteboard.general
+ // 清除剪贴板现有内容
+ // Clear existing pasteboard content
+ pasteboard.clearContents()
+ // 将文件URL添加到剪贴板
+ // Add file URLs to pasteboard
+ pasteboard.writeObjects(publicVar.selectedUrls() as [NSPasteboardWriting])
+ // 复制操作重置剪切模式
+ // Copy operation resets cut mode
+ globalVar.isCutMode = false
+ clearCutItemsDimEffect()
+ }
+
+ func handleCopyToDownload() {
+ if publicVar.selectedUrls().isEmpty {return}
+
+ // 备份剪贴板内容
+ // Backup pasteboard content
+ let backupItems = backupPasteboard()
+
+ handleCopy()
+ handlePaste(targetURL: FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first)
+
+ // 还原剪贴板内容
+ // Restore pasteboard content
+ restorePasteboard(items: backupItems)
+ }
+
+ func handleCopyToPhotoFolder1() {
+ let selectedURLs = publicVar.selectedUrls()
+ if selectedURLs.isEmpty { return }
+ handleCopyToConfiguredFolder(
+ selectedURLs: selectedURLs,
+ targetPath: globalVar.photoFolder1Path,
+ emptyPathMessage: NSLocalizedString("Please set Photo Folder 1 in Settings first.", comment: "请先在设置中配置图片文件夹1。"),
+ invalidPathMessage: NSLocalizedString("Photo Folder 1 does not exist or is not a folder.", comment: "图片文件夹1不存在或不是文件夹。")
+ )
+ }
+
+ func handleCopySelectedVideosToPhotoFolder2() {
+ let selectedURLs = publicVar.selectedUrls()
+ if selectedURLs.isEmpty { return }
+
+ let videoURLs = selectedURLs.filter { isVideoURLForFolder2Copy($0) }
+ guard !videoURLs.isEmpty else {
+ showAlert(message: NSLocalizedString("Please select at least one video first.", comment: "请先选择至少一个视频。"))
+ return
+ }
+
+ handleCopyToConfiguredFolder(
+ selectedURLs: videoURLs,
+ targetPath: globalVar.photoFolder2Path,
+ emptyPathMessage: NSLocalizedString("Please set Video Folder 2 in Settings first.", comment: "请先在设置中配置视频文件夹2。"),
+ invalidPathMessage: NSLocalizedString("Video Folder 2 does not exist or is not a folder.", comment: "视频文件夹2不存在或不是文件夹。")
+ )
+ }
+
+ func handleCopyCurrentVideoToPhotoFolder2() {
+ guard publicVar.isInLargeView,
+ largeImageView.file.type == .video,
+ let currentURL = URL(string: largeImageView.file.path) else {
+ return
+ }
+
+ handleCopyToConfiguredFolder(
+ selectedURLs: [currentURL],
+ targetPath: globalVar.photoFolder2Path,
+ emptyPathMessage: NSLocalizedString("Please set Video Folder 2 in Settings first.", comment: "请先在设置中配置视频文件夹2。"),
+ invalidPathMessage: NSLocalizedString("Video Folder 2 does not exist or is not a folder.", comment: "视频文件夹2不存在或不是文件夹。")
+ )
+ }
+
+ private func showPhotoFolderCopyToast(selectedURLs: [URL], targetFolderURL: URL) {
+ guard !selectedURLs.isEmpty else { return }
+ let firstName = selectedURLs[0].lastPathComponent.removingPercentEncoding ?? selectedURLs[0].lastPathComponent
+ let targetName = targetFolderURL.lastPathComponent.isEmpty ? targetFolderURL.path : targetFolderURL.lastPathComponent
+ let message: String
+ if selectedURLs.count == 1 {
+ message = "\(firstName) -> \(targetName)"
+ } else {
+ message = "\(firstName) +\(selectedURLs.count - 1) -> \(targetName)"
+ }
+ DispatchQueue.main.async { [weak self] in
+ self?.coreAreaView.showOperationToast(message, autoHide: 2.0)
+ }
+ }
+
+ private func handleCopyToConfiguredFolder(selectedURLs: [URL], targetPath: String, emptyPathMessage: String, invalidPathMessage: String) {
+ guard !selectedURLs.isEmpty else { return }
+
+ let normalizedTargetPath = targetPath.trimmingCharacters(in: .whitespacesAndNewlines)
+ if normalizedTargetPath.isEmpty {
+ showAlert(message: emptyPathMessage)
+ return
+ }
+
+ var isDirectory: ObjCBool = false
+ if !FileManager.default.fileExists(atPath: normalizedTargetPath, isDirectory: &isDirectory) || !isDirectory.boolValue {
+ showAlert(message: invalidPathMessage)
+ return
+ }
+
+ let targetFolderURL = URL(fileURLWithPath: normalizedTargetPath, isDirectory: true)
+ for sourceURL in selectedURLs where !isVirtualArchiveEntryPath(sourceURL.absoluteString) {
+ if sourceURL == targetFolderURL || targetFolderURL.path.hasPrefix(sourceURL.path + "/") {
+ showAlert(message: NSLocalizedString("cannot-copy-to-self", comment: "不能将文件/文件夹复制到自身或其子目录中。"))
+ return
+ }
+ }
+ publicVar.isInFileOperation = true
+ coreAreaView.showOperationIndeterminate("正在复制到 \(targetFolderURL.lastPathComponent)…")
+
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ guard let self = self else { return }
+
+ let fileManager = FileManager.default
+ var reservedTargetPaths = Set()
+ var copiedURLs: [URL] = []
+ var failedItems: [String] = []
+
+ func uniqueDestination(for fileName: String) -> URL {
+ let candidate = targetFolderURL.appendingPathComponent(fileName)
+ var destination = candidate
+ var index = 2
+ while fileManager.fileExists(atPath: destination.path) || reservedTargetPaths.contains(destination.path.lowercased()) {
+ let stem = candidate.deletingPathExtension().lastPathComponent
+ let ext = candidate.pathExtension
+ let renamed = ext.isEmpty ? "\(stem)_\(index)" : "\(stem)_\(index).\(ext)"
+ destination = targetFolderURL.appendingPathComponent(renamed)
+ index += 1
+ }
+ reservedTargetPaths.insert(destination.path.lowercased())
+ return destination
+ }
+
+ for (offset, sourceURL) in selectedURLs.enumerated() {
+ DispatchQueue.main.async { [weak self] in
+ self?.coreAreaView.showOperationProgress(
+ "正在复制 \(offset + 1)/\(selectedURLs.count):\(sourceURL.lastPathComponent)",
+ progress: Double(offset) / Double(selectedURLs.count)
+ )
+ }
+
+ if isVirtualArchiveEntryPath(sourceURL.absoluteString) {
+ guard let parsed = parseVirtualArchivePath(sourceURL.absoluteString),
+ let entryPath = parsed.entryPath,
+ let data = getArchiveEntryData(archiveURL: parsed.archiveURL, entryPath: entryPath) else {
+ failedItems.append(sourceURL.lastPathComponent.removingPercentEncoding ?? sourceURL.lastPathComponent)
+ continue
+ }
+ let fileName = URL(fileURLWithPath: entryPath).lastPathComponent
+ let destinationURL = uniqueDestination(for: fileName)
+ do {
+ try data.write(to: destinationURL, options: .atomic)
+ copiedURLs.append(destinationURL)
+ } catch {
+ log("Copy archive entry failed: \(error)", level: .error)
+ failedItems.append(fileName)
+ }
+ } else {
+ let destinationURL = uniqueDestination(for: sourceURL.lastPathComponent)
+ do {
+ try fileManager.copyItem(at: sourceURL, to: destinationURL)
+ copiedURLs.append(destinationURL)
+ } catch {
+ log("Copy file failed: \(error)", level: .error)
+ failedItems.append(sourceURL.lastPathComponent)
+ }
+ }
+ }
+
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+ self.publicVar.isInFileOperation = false
+
+ if !copiedURLs.isEmpty {
+ self.publicVar.fileChangedCount += copiedURLs.count
+ self.publicVar.filesForLocateAfterChange = copiedURLs.map(\.absoluteString)
+ self.publicVar.filesForLocateAfterChangeTime = .now()
+ triggerFinderSound()
+ self.scheduledRefresh()
+ self.showPhotoFolderCopyToast(selectedURLs: selectedURLs, targetFolderURL: targetFolderURL)
+ self.coreAreaView.showOperationProgress("复制完成", progress: 1.0)
+ self.coreAreaView.hideOperationOverlay(delayed: 0.8)
+ } else {
+ self.coreAreaView.hideOperationOverlay(delayed: 0.2)
+ }
+
+ if !failedItems.isEmpty {
+ let preview = failedItems.prefix(3).joined(separator: ", ")
+ showAlert(message: String(format: NSLocalizedString("Failed to copy some files: %@", comment: "部分文件复制失败:%@"), preview))
+ }
+ }
+ }
+ }
+
+ private func isVideoURLForFolder2Copy(_ url: URL) -> Bool {
+ if isVirtualArchiveEntryPath(url.absoluteString),
+ let parsed = parseVirtualArchivePath(url.absoluteString),
+ let entryPath = parsed.entryPath {
+ let ext = URL(fileURLWithPath: entryPath).pathExtension.lowercased()
+ return globalVar.HandledVideoExtensions.contains(ext)
+ }
+ return globalVar.HandledVideoExtensions.contains(url.pathExtension.lowercased())
+ }
+
+ func promptCompressionPassword(initialValue: String = "") -> String? {
+ let alert = NSAlert()
+ alert.messageText = NSLocalizedString("Encrypt ZIP", comment: "加密压缩 ZIP")
+ alert.informativeText = NSLocalizedString("Please input ZIP password:", comment: "请输入 ZIP 密码:")
+ alert.alertStyle = .informational
+ alert.icon = NSImage(named: NSImage.infoName)
+
+ let passwordField = NSSecureTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
+ passwordField.stringValue = initialValue
+ alert.accessoryView = passwordField
+
+ alert.addButton(withTitle: NSLocalizedString("OK", comment: "确定"))
+ alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "取消"))
+
+ let old = publicVar.isKeyEventEnabled
+ publicVar.isKeyEventEnabled = false
+ DispatchQueue.main.async { _ = passwordField.becomeFirstResponder() }
+ let response = alert.runModal()
+ publicVar.isKeyEventEnabled = old
+
+ guard response == .alertFirstButtonReturn else { return nil }
+ let password = passwordField.stringValue
+ if password.isEmpty {
+ showAlert(message: NSLocalizedString("Password cannot be empty.", comment: "密码不能为空。"))
+ return nil
+ }
+ return password
+ }
+
+ private func makeZipDestinationURL(for urls: [URL]) -> URL? {
+ guard !urls.isEmpty else { return nil }
+ let parent = urls[0].deletingLastPathComponent()
+ if urls.count == 1 {
+ let base = urls[0].deletingPathExtension().lastPathComponent
+ return getUniqueDestinationURL(for: parent.appendingPathComponent(base).appendingPathExtension("zip"), isInPlace: false)
+ }
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyyMMdd_HHmmss"
+ let stamp = formatter.string(from: Date())
+ return getUniqueDestinationURL(for: parent.appendingPathComponent("Archive_\(stamp)").appendingPathExtension("zip"), isInPlace: false)
+ }
+
+ private func collectCompressMetrics(urls: [URL]) -> (totalBytes: Int64, totalFiles: Int) {
+ let fm = FileManager.default
+ var totalBytes: Int64 = 0
+ var totalFiles = 0
+
+ func addFile(_ url: URL) {
+ let values = try? url.resourceValues(forKeys: [.fileSizeKey, .totalFileAllocatedSizeKey, .fileAllocatedSizeKey])
+ let size = values?.totalFileAllocatedSize
+ ?? values?.fileAllocatedSize
+ ?? values?.fileSize
+ ?? 0
+ totalBytes += Int64(size)
+ totalFiles += 1
+ }
+
+ for url in urls {
+ var isDir: ObjCBool = false
+ if fm.fileExists(atPath: url.path, isDirectory: &isDir), isDir.boolValue {
+ if let enumerator = fm.enumerator(at: url, includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey, .totalFileAllocatedSizeKey, .fileAllocatedSizeKey], options: [], errorHandler: nil) {
+ while let subURL = enumerator.nextObject() as? URL {
+ let isDirectory = (try? subURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
+ if !isDirectory {
+ addFile(subURL)
+ }
+ }
+ }
+ } else {
+ addFile(url)
+ }
+ }
+
+ return (totalBytes, max(totalFiles, 1))
+ }
+
+ private func buildCompressCommand(urls: [URL], destination: URL, mode: CompressMode) -> (args: [String], workDir: URL)? {
+ guard !urls.isEmpty else { return nil }
+ let workDir = urls[0].deletingLastPathComponent()
+ var relativeNames: [String] = []
+ for url in urls {
+ if url.deletingLastPathComponent() != workDir {
+ return nil
+ }
+ relativeNames.append(url.lastPathComponent)
+ }
+ var args: [String] = ["-r", "-y"]
+ switch mode {
+ case .plainZip:
+ break
+ case .encryptedZip(let password):
+ args += ["-P", password]
+ }
+ args.append(destination.path)
+ args.append(contentsOf: relativeNames)
+ return (args, workDir)
+ }
+
+ @discardableResult
+ func handleCompress(urls inputUrls: [URL] = [], mode: CompressMode, deleteOriginal: Bool) -> Bool {
+ var urls = inputUrls
+ if urls.isEmpty {
+ urls = publicVar.selectedUrls()
+ }
+ if urls.isEmpty { return false }
+
+ if urls.contains(where: { isReadOnlyVirtualFolderPath($0.absoluteString) || isVirtualArchiveEntryPath($0.absoluteString) }) {
+ showAlert(message: NSLocalizedString("Virtual entries cannot be compressed.", comment: "虚拟目录或压缩包内虚拟条目不支持压缩。"))
+ return false
+ }
+
+ let sortedUrls = urls.sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending }
+ guard let destinationURL = makeZipDestinationURL(for: sortedUrls) else { return false }
+ guard let (args, workDir) = buildCompressCommand(urls: sortedUrls, destination: destinationURL, mode: mode) else {
+ showAlert(message: NSLocalizedString("Please select items from the same folder.", comment: "请在同一目录下选择要压缩的项目。"))
+ return false
+ }
+
+ let metrics = collectCompressMetrics(urls: sortedUrls)
+ let shouldShowOverlayProgress = metrics.totalBytes >= 100 * 1024 * 1024
+
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
+ process.currentDirectoryURL = workDir
+ process.arguments = args
+ let stdErr = Pipe()
+ let stdOut = Pipe()
+ process.standardError = stdErr
+ process.standardOutput = stdOut
+
+ if shouldShowOverlayProgress {
+ DispatchQueue.main.async { [weak self] in
+ self?.coreAreaView.showOperationProgress(NSLocalizedString("Compressing... 0%", comment: "压缩中... 0%"), progress: 0)
+ }
+ }
+
+ let parseQueue = DispatchQueue(label: "flowvision.compress.stdout.parse")
+ var processedFiles = 0
+ let progressUpdateStep = max(1, metrics.totalFiles / 100)
+ stdOut.fileHandleForReading.readabilityHandler = { [weak self] handle in
+ let data = handle.availableData
+ if data.isEmpty { return }
+ guard shouldShowOverlayProgress else { return }
+ guard let output = String(data: data, encoding: .utf8), !output.isEmpty else { return }
+ parseQueue.async {
+ let lines = output.split(whereSeparator: \.isNewline)
+ for line in lines {
+ if line.contains("adding:") {
+ processedFiles += 1
+ }
+ }
+ if processedFiles == 0 { return }
+ if processedFiles % progressUpdateStep != 0 && processedFiles < metrics.totalFiles { return }
+ let ratio = min(1.0, Double(processedFiles) / Double(metrics.totalFiles))
+ DispatchQueue.main.async {
+ self?.coreAreaView.showOperationProgress(
+ String(format: NSLocalizedString("Compressing... %d%%", comment: "压缩中... %d%%"), Int(ratio * 100)),
+ progress: ratio
+ )
+ }
+ }
+ }
+
+ do {
+ try process.run()
+ process.waitUntilExit()
+ } catch {
+ stdOut.fileHandleForReading.readabilityHandler = nil
+ showAlert(message: NSLocalizedString("Failed to execute zip.", comment: "执行压缩失败。"))
+ log("zip execute failed: \(error)", level: .error)
+ if shouldShowOverlayProgress {
+ DispatchQueue.main.async { [weak self] in
+ self?.coreAreaView.hideOperationOverlay()
+ }
+ }
+ return false
+ }
+ stdOut.fileHandleForReading.readabilityHandler = nil
+
+ guard process.terminationStatus == 0 else {
+ let errMsg = String(data: stdErr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
+ if !errMsg.isEmpty { log("zip failed: \(errMsg)", level: .error) }
+ showAlert(message: NSLocalizedString("Compression failed.", comment: "压缩失败。"))
+ if shouldShowOverlayProgress {
+ DispatchQueue.main.async { [weak self] in
+ self?.coreAreaView.hideOperationOverlay()
+ }
+ }
+ return false
+ }
+
+ if shouldShowOverlayProgress {
+ DispatchQueue.main.async { [weak self] in
+ self?.coreAreaView.showOperationProgress(NSLocalizedString("Compression complete", comment: "压缩完成"), progress: 1.0)
+ self?.coreAreaView.hideOperationOverlay(delayed: 0.8)
+ }
+ }
+
+ publicVar.fileChangedCount += 1
+ publicVar.filesForLocateAfterChange = [destinationURL.absoluteString]
+ var logText = "[Compress] \(sortedUrls.count) item(s) -> \(destinationURL.lastPathComponent)"
+ if deleteOriginal {
+ for url in sortedUrls {
+ _ = try? FileManager.default.trashItem(at: url, resultingItemURL: nil)
+ }
+ publicVar.fileChangedCount += sortedUrls.count
+ logText += " + delete source"
+ }
+ globalVar.operationLogs.append(logText)
+ scheduledRefresh()
+ return true
}
-
- func handleNewFolderWithSelection() {
- var urls = publicVar.selectedUrls()
- if urls.isEmpty {return}
-
- let (ifSuccess,newFolderURL) = handleNewFolder()
-
- if ifSuccess {
- // 备份剪贴板内容
- // Backup pasteboard content
- let backupItems = backupPasteboard()
-
- handleCopy()
- handleMove(targetURL: newFolderURL)
-
- if let newFolderURL = newFolderURL {
- publicVar.filesForLocateAfterChange = [newFolderURL.absoluteString]
- publicVar.filesForLocateAfterChangeTime = .now()
+
+ @discardableResult
+ func handleCompressByDefaultSetting(urls: [URL] = [], deleteOriginal: Bool = false) -> Bool {
+ if globalVar.compressionUseDefaultPassword {
+ let password = globalVar.compressionDefaultPassword.trimmingCharacters(in: .whitespacesAndNewlines)
+ if password.isEmpty {
+ showAlert(message: NSLocalizedString("Default compression password is empty. Please set it in Settings.", comment: "默认压缩密码为空,请先在设置中配置。"))
+ return false
}
-
- // 还原剪贴板内容
- // Restore pasteboard content
- restorePasteboard(items: backupItems)
+ return handleCompress(urls: urls, mode: .encryptedZip(password: password), deleteOriginal: deleteOriginal)
}
-
+ return handleCompress(urls: urls, mode: .plainZip, deleteOriginal: deleteOriginal)
}
-
-// // 备份剪贴板内容的函数
-// func backupPasteboard() -> [NSPasteboard.PasteboardType: Any] {
-// let pasteboard = NSPasteboard.general
-// var backupItems = [NSPasteboard.PasteboardType: Any]()
-//
-// for type in pasteboard.types ?? [] {
-// if let item = pasteboard.data(forType: type) {
-// backupItems[type] = item
-// }
-// }
-//
-// return backupItems
-// }
-//
-// // 还原剪贴板内容的函数
-// func restorePasteboard(items: [NSPasteboard.PasteboardType: Any]) {
-// let pasteboard = NSPasteboard.general
-// pasteboard.clearContents()
-//
-// for (type, item) in items {
-// if let data = item as? Data {
-// pasteboard.setData(data, forType: type)
-// }
-// }
-// }
-
- // 备份剪贴板内容的函数
- // Function to backup pasteboard content
- func backupPasteboard() -> [[String: Data]] {
- let pasteboard = NSPasteboard.general
- var backupItems = [[String: Data]]()
-
- for item in pasteboard.pasteboardItems ?? [] {
- var backupItem = [String: Data]()
- for type in item.types {
- if let data = item.data(forType: type) {
- backupItem[type.rawValue] = data
+
+ private func archiveBaseName(for url: URL) -> String {
+ let lowerName = url.lastPathComponent.lowercased()
+ let multiExtensions = [".tar.gz", ".tar.bz2", ".tar.xz"]
+ if let matched = multiExtensions.first(where: { lowerName.hasSuffix($0) }) {
+ return String(url.lastPathComponent.dropLast(matched.count))
+ }
+ return url.deletingPathExtension().lastPathComponent
+ }
+
+ private func makeExtractDestinationURL(for archiveURL: URL) -> URL {
+ let parent = archiveURL.deletingLastPathComponent()
+ let base = archiveBaseName(for: archiveURL)
+ return getUniqueDestinationURL(for: parent.appendingPathComponent(base), isInPlace: false)
+ }
+
+ @discardableResult
+ func handleExtractArchives(urls inputUrls: [URL] = [], deleteOriginal: Bool) -> Bool {
+ var urls = inputUrls
+ if urls.isEmpty {
+ urls = publicVar.selectedUrls()
+ }
+ if urls.isEmpty { return false }
+
+ let archiveURLs = urls.filter {
+ !$0.absoluteString.isEmpty &&
+ !isReadOnlyVirtualFolderPath($0.absoluteString) &&
+ !isVirtualArchiveEntryPath($0.absoluteString) &&
+ isSupportedArchiveURL($0)
+ }.sorted { $0.path.localizedStandardCompare($1.path) == .orderedAscending }
+
+ guard !archiveURLs.isEmpty else {
+ showAlert(message: NSLocalizedString("Please select archive files first.", comment: "请先选择压缩包文件。"))
+ return false
+ }
+
+ var extractedDestinations: [URL] = []
+ var failedArchives: [String] = []
+ let fm = FileManager.default
+
+ for archiveURL in archiveURLs {
+ let destinationURL = makeExtractDestinationURL(for: archiveURL)
+ do {
+ try fm.createDirectory(at: destinationURL, withIntermediateDirectories: true)
+ } catch {
+ log("create extract dir failed: \(error)", level: .error)
+ failedArchives.append(archiveURL.lastPathComponent)
+ continue
+ }
+
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/bsdtar")
+ process.arguments = ["-xf", archiveURL.path, "-C", destinationURL.path]
+ let stdErr = Pipe()
+ process.standardError = stdErr
+
+ do {
+ try process.run()
+ process.waitUntilExit()
+ } catch {
+ log("extract execute failed: \(error)", level: .error)
+ failedArchives.append(archiveURL.lastPathComponent)
+ try? fm.removeItem(at: destinationURL)
+ continue
+ }
+
+ guard process.terminationStatus == 0 else {
+ let errMsg = String(data: stdErr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? ""
+ if !errMsg.isEmpty {
+ log("extract failed: \(errMsg)", level: .error)
}
+ failedArchives.append(archiveURL.lastPathComponent)
+ try? fm.removeItem(at: destinationURL)
+ continue
+ }
+
+ extractedDestinations.append(destinationURL)
+ if deleteOriginal {
+ _ = try? fm.trashItem(at: archiveURL, resultingItemURL: nil)
}
- backupItems.append(backupItem)
}
-
- return backupItems
+
+ guard !extractedDestinations.isEmpty else {
+ showAlert(message: NSLocalizedString("Extraction failed.", comment: "解压失败。"))
+ return false
+ }
+
+ publicVar.fileChangedCount += extractedDestinations.count + (deleteOriginal ? archiveURLs.count : 0)
+ publicVar.filesForLocateAfterChange = extractedDestinations.map { $0.absoluteString }
+ var logText = "[Extract] \(archiveURLs.count) archive(s)"
+ if deleteOriginal {
+ logText += " + delete source"
+ }
+ globalVar.operationLogs.append(logText)
+ scheduledRefresh()
+
+ if !failedArchives.isEmpty {
+ let preview = failedArchives.prefix(3).joined(separator: ", ")
+ showAlert(message: String(format: NSLocalizedString("Failed to extract some archives: %@", comment: "部分压缩包解压失败:%@"), preview))
+ }
+ return true
}
- // 还原剪贴板内容的函数
- // Function to restore pasteboard content
- func restorePasteboard(items: [[String: Data]]) {
- let pasteboard = NSPasteboard.general
- pasteboard.clearContents()
-
- for itemData in items {
- let newItem = NSPasteboardItem()
- for (type, data) in itemData {
- newItem.setData(data, forType: NSPasteboard.PasteboardType(rawValue: type))
+ func handleCaptureCurrentVideoFrameToCurrentFolder() {
+ guard publicVar.isInLargeView,
+ largeImageView.file.type == .video,
+ let videoURL = URL(string: largeImageView.file.path),
+ videoURL.isFileURL else {
+ return
+ }
+
+ var captureTime = CMTime(seconds: largeImageView.videoCurrentTimeSeconds, preferredTimescale: 600)
+ if !captureTime.isValid || captureTime == .indefinite {
+ captureTime = CMTime(seconds: 0, preferredTimescale: 600)
+ }
+
+ let generator = AVAssetImageGenerator(asset: AVAsset(url: videoURL))
+ generator.appliesPreferredTrackTransform = true
+ generator.requestedTimeToleranceBefore = .zero
+ generator.requestedTimeToleranceAfter = .zero
+
+ do {
+ let cgImage = try generator.copyCGImage(at: captureTime, actualTime: nil)
+ let bitmap = NSBitmapImageRep(cgImage: cgImage)
+ guard let pngData = bitmap.representation(using: .png, properties: [:]) else {
+ showAlert(message: NSLocalizedString("Failed to encode captured frame.", comment: "编码截图失败。"))
+ return
}
- pasteboard.writeObjects([newItem])
+
+ let baseName = videoURL.deletingPathExtension().lastPathComponent
+ let ms = max(0, Int(CMTimeGetSeconds(captureTime).isFinite ? CMTimeGetSeconds(captureTime) * 1000 : 0))
+ let fileName = "\(baseName)_frame_\(ms)"
+ let outputCandidate = videoURL.deletingLastPathComponent().appendingPathComponent(fileName).appendingPathExtension("png")
+ let outputURL = getUniqueDestinationURL(for: outputCandidate)
+
+ try pngData.write(to: outputURL, options: .atomic)
+ publicVar.fileChangedCount += 1
+ // Preserve the current video after refresh so the newly saved frame
+ // doesn't take over the current large-view position and pause playback.
+ publicVar.openFromFinderPath = videoURL.absoluteString
+ scheduledRefresh()
+ largeImageView.showInfo(NSLocalizedString("Frame Saved", comment: "视频帧已保存"))
+ } catch {
+ log("Capture video frame failed: \(error)", level: .error)
+ showAlert(message: NSLocalizedString("Failed to capture current video frame.", comment: "抓取当前视频帧失败。"))
}
}
-
- func handleCopy() {
- let pasteboard = NSPasteboard.general
- // 清除剪贴板现有内容
- // Clear existing pasteboard content
- pasteboard.clearContents()
- // 将文件URL添加到剪贴板
- // Add file URLs to pasteboard
- pasteboard.writeObjects(publicVar.selectedUrls() as [NSPasteboardWriting])
- // 复制操作重置剪切模式
- // Copy operation resets cut mode
- globalVar.isCutMode = false
- clearCutItemsDimEffect()
- }
-
- func handleCopyToDownload() {
- if publicVar.selectedUrls().isEmpty {return}
-
- // 备份剪贴板内容
- // Backup pasteboard content
- let backupItems = backupPasteboard()
-
- handleCopy()
- handlePaste(targetURL: FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first)
-
- // 还原剪贴板内容
- // Restore pasteboard content
- restorePasteboard(items: backupItems)
+
+ @discardableResult
+ func handleCollectFilesFromSubfolders() -> Bool {
+ let selectedURLs = publicVar.selectedUrls()
+ guard selectedURLs.count > 1 else { return false }
+
+ if selectedURLs.contains(where: { isReadOnlyVirtualFolderPath($0.absoluteString) || isVirtualArchiveEntryPath($0.absoluteString) }) {
+ showAlert(message: NSLocalizedString("Virtual entries are not supported for this operation.", comment: "该操作不支持虚拟目录或压缩包内虚拟条目。"))
+ return false
+ }
+
+ let folderURLs = selectedURLs.filter { $0.hasDirectoryPath }
+ guard folderURLs.count == selectedURLs.count else {
+ showAlert(message: NSLocalizedString("Please select folders only.", comment: "请仅选择文件夹。"))
+ return false
+ }
+
+ fileDB.lock()
+ let curFolder = fileDB.curFolder
+ fileDB.unlock()
+
+ guard let currentFolderURL = URL(string: curFolder), currentFolderURL.isFileURL else {
+ showAlert(message: NSLocalizedString("Invalid current path", comment: "当前路径无效"))
+ return false
+ }
+
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyyMMdd_HHmmss"
+ let folderName = "CollectedFiles_\(formatter.string(from: Date()))"
+ let targetFolderURL = getUniqueDestinationURL(for: currentFolderURL.appendingPathComponent(folderName), isInPlace: false)
+
+ do {
+ try FileManager.default.createDirectory(at: targetFolderURL, withIntermediateDirectories: true, attributes: nil)
+ } catch {
+ log("Create collected folder failed: \(error)", level: .error)
+ showAlert(message: NSLocalizedString("Failed to create collection folder.", comment: "创建归集文件夹失败。"))
+ return false
+ }
+
+ var copiedCount = 0
+ var failedCount = 0
+ var copiedURLs: [String] = []
+
+ for folderURL in folderURLs {
+ guard let enumerator = FileManager.default.enumerator(
+ at: folderURL,
+ includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey],
+ options: [],
+ errorHandler: { url, error in
+ log("Enumerate failed \(url): \(error)", level: .warn)
+ return true
+ }
+ ) else { continue }
+
+ while let itemURL = enumerator.nextObject() as? URL {
+ let values = try? itemURL.resourceValues(forKeys: [.isDirectoryKey, .isRegularFileKey])
+ if values?.isDirectory == true { continue }
+ guard values?.isRegularFile == true else { continue }
+
+ let targetURL = getUniqueDestinationURL(for: targetFolderURL.appendingPathComponent(itemURL.lastPathComponent), isInPlace: false)
+ do {
+ try FileManager.default.copyItem(at: itemURL, to: targetURL)
+ copiedCount += 1
+ copiedURLs.append(targetURL.absoluteString)
+ } catch {
+ failedCount += 1
+ log("Copy collected file failed: \(error)", level: .warn)
+ }
+ }
+ }
+
+ if copiedCount == 0 {
+ try? FileManager.default.removeItem(at: targetFolderURL)
+ let message = failedCount > 0
+ ? NSLocalizedString("No files were collected from subfolders.", comment: "未能从子文件夹中归集到文件。")
+ : NSLocalizedString("No files found in subfolders.", comment: "子文件夹中未找到可归集文件。")
+ showAlert(message: message)
+ return false
+ }
+
+ publicVar.fileChangedCount += copiedCount + 1
+ publicVar.filesForLocateAfterChange = [targetFolderURL.absoluteString]
+ globalVar.operationLogs.append("[Collect] \(copiedCount) files -> \(targetFolderURL.lastPathComponent)")
+ scheduledRefresh()
+
+ let infoText: String
+ if failedCount > 0 {
+ infoText = String(format: NSLocalizedString("Collected %d files (%d failed)", comment: "已归集 %d 个文件(%d 个失败)"), copiedCount, failedCount)
+ } else {
+ infoText = String(format: NSLocalizedString("Collected %d files", comment: "已归集 %d 个文件"), copiedCount)
+ }
+ coreAreaView.showOperationToast(infoText + " -> " + targetFolderURL.lastPathComponent, autoHide: 2.0)
+ return true
}
-
+
func handlePaste(targetURL: URL? = nil, pasteboard: NSPasteboard = NSPasteboard.general) {
// 如果是剪切模式,执行移动操作而非复制
// If in cut mode, perform move operation instead of copy
@@ -353,9 +2341,9 @@ extension ViewController {
handleMove(targetURL: targetURL, pasteboard: pasteboard)
return
}
-
+
guard let items = pasteboard.pasteboardItems else { return }
-
+
fileDB.lock()
let curFolder = fileDB.curFolder
fileDB.unlock()
@@ -366,15 +2354,15 @@ extension ViewController {
destinationURL = URL(string: curFolder)
}
guard let destinationURL = destinationURL else { return }
-
+
// 检查待复制的文件/文件夹列表
// Check list of files/folders to copy
for item in items {
guard let fileURL = URL(string: item.string(forType: .fileURL) ?? "") else { continue }
-
+
// 检查是否包含目标目录自身或者它的父目录
// Check if includes destination directory itself or its parent directory
- if fileURL == destinationURL || destinationURL.path.hasPrefix(fileURL.path) {
+ if isSameOrDescendant(destinationURL, of: fileURL) {
showAlert(message: NSLocalizedString("cannot-copy-to-self", comment: "不能将文件/文件夹复制到自身或其子目录中。"))
return
}
@@ -394,7 +2382,7 @@ extension ViewController {
}
fileNames.insert(fileName)
}
-
+
// 如果有同名文件,弹窗询问是否继续
// If there are files with same name, show dialog asking whether to continue
if hasDuplicates {
@@ -407,7 +2395,7 @@ extension ViewController {
alert.icon = NSImage(named: NSImage.infoName)
alert.addButton(withTitle: NSLocalizedString("Auto Rename", comment: "自动重命名"))
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "取消"))
-
+
let StoreIsKeyEventEnabled = publicVar.isKeyEventEnabled
publicVar.isKeyEventEnabled = false
defer {
@@ -427,17 +2415,17 @@ extension ViewController {
guard let fileURL = URL(string: item.string(forType: .fileURL) ?? "") else { return nil }
return fileURL.lastPathComponent
}
-
+
let sourceFilesStr: String
if sourceFiles.count > 3 {
sourceFilesStr = sourceFiles[0...2].joined(separator: ", ") + "..."
} else {
sourceFilesStr = sourceFiles.joined(separator: ", ")
}
-
+
let operationLog = "[Paste] \(sourceFilesStr) -> \(destinationURL.lastPathComponent)"
globalVar.operationLogs.append(operationLog)
-
+
// 在文件操作期间抑制文件系统监控触发的刷新,操作完成后主动刷新
// Suppress FS watcher refreshes during file operations, refresh explicitly after completion
publicVar.isInFileOperation = true
@@ -452,7 +2440,7 @@ extension ViewController {
publicVar.filesForLocateAfterChange = successfulDestURLs
publicVar.filesForLocateAfterChangeTime = .now()
var ifRefresh = true
- if publicVar.isRecursiveMode || curFolder.hasPrefix("file:///VirtualFinderTagsFolder") {
+ if publicVar.isRecursiveMode || isVirtualFolderPath(curFolder) {
fileDB.lock()
ifRefresh = fileDB.db[SortKeyDir(fileDB.curFolder)]?.files.count ?? 0 <= RESET_VIEW_FILE_NUM_THRESHOLD
fileDB.unlock()
@@ -476,13 +2464,13 @@ extension ViewController {
EnhancedIndex.handleFilesCopied(indexCopyPairs)
}
}
-
+
var shouldReplaceAll = false
var shouldMergeAll = false
var shouldSkipAll = false
var shouldAutoRenameAll = false
let sharedMergeState = MergeConflictState()
-
+
let StoreIsKeyEventEnabled = publicVar.isKeyEventEnabled
publicVar.isKeyEventEnabled = false
for item in items {
@@ -493,14 +2481,14 @@ extension ViewController {
if ifAutoRenameWhenDifferentSource {
destURL = getUniqueDestinationURL(for: destURL, isInPlace: false)
}
-
+
// 如果是在同一目录复制粘贴,则修改名称
// If copying/pasting in same directory, modify name
var isInSameFolder = fileURL.deletingLastPathComponent() == destinationURL
if isInSameFolder {
destURL = getUniqueDestinationURL(for: destURL, isInPlace: true)
}
-
+
if FileManager.default.fileExists(atPath: destURL.path) {
// 检测源和目标是否都是文件夹
// Check if both source and destination are folders
@@ -509,7 +2497,7 @@ extension ViewController {
var dstIsDir: ObjCBool = false
FileManager.default.fileExists(atPath: destURL.path, isDirectory: &dstIsDir)
let bothAreFolders = srcIsDir.boolValue && dstIsDir.boolValue
-
+
if shouldReplaceAll {
do {
try FileManager.default.removeItem(at: destURL)
@@ -626,38 +2614,43 @@ extension ViewController {
}
publicVar.isKeyEventEnabled = StoreIsKeyEventEnabled
}
-
+
func handleMoveToDownload() {
if publicVar.selectedUrls().isEmpty {return}
-
+
// 备份剪贴板内容
// Backup pasteboard content
let backupItems = backupPasteboard()
-
+
handleCopy()
handleMove(targetURL: FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first)
-
+
// 还原剪贴板内容
// Restore pasteboard content
restorePasteboard(items: backupItems)
}
- func handleMove(targetURL: URL? = nil, pasteboard: NSPasteboard = NSPasteboard.general) {
-
+ func handleMove(
+ targetURL: URL? = nil,
+ pasteboard: NSPasteboard = NSPasteboard.general,
+ allowBackgroundPreflight: Bool = true,
+ knownExistingTargetPaths: Set? = nil
+ ) {
+
// 重置剪切模式,防止直接调用handleMove后isCutMode残留为true
// Reset cut mode to prevent isCutMode remaining true after direct handleMove calls
globalVar.isCutMode = false
clearCutItemsDimEffect()
-
+
// 按住Option则为复制
// Hold Option to copy
if isOptionKeyPressed() && !isCommandKeyPressed() {
handlePaste(targetURL: targetURL, pasteboard: pasteboard)
return
}
-
+
guard let items = pasteboard.pasteboardItems else { return }
-
+
fileDB.lock()
let curFolder = fileDB.curFolder
fileDB.unlock()
@@ -668,15 +2661,15 @@ extension ViewController {
destinationURL = URL(string: curFolder)
}
guard let destinationURL = destinationURL else { return }
-
+
// 检查待移动的文件/文件夹列表
// Check list of files/folders to move
for item in items {
guard let fileURL = URL(string: item.string(forType: .fileURL) ?? "") else { continue }
-
+
// 检查是否包含目标目录自身或者它的父目录
// Check if includes destination directory itself or its parent directory
- if fileURL == destinationURL || destinationURL.path.hasPrefix(fileURL.path) {
+ if isSameOrDescendant(destinationURL, of: fileURL) {
showAlert(message: NSLocalizedString("cannot-move-to-self", comment: "不能将文件/文件夹移动到自身或其子目录中。"))
return
}
@@ -696,7 +2689,7 @@ extension ViewController {
}
fileNames.insert(fileName)
}
-
+
// 如果有同名文件,弹窗询问是否继续
// If there are files with same name, show dialog asking whether to continue
if hasDuplicates {
@@ -709,7 +2702,7 @@ extension ViewController {
alert.icon = NSImage(named: NSImage.infoName)
alert.addButton(withTitle: NSLocalizedString("Auto Rename", comment: "自动重命名"))
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "取消"))
-
+
let StoreIsKeyEventEnabled = publicVar.isKeyEventEnabled
publicVar.isKeyEventEnabled = false
defer {
@@ -722,24 +2715,50 @@ extension ViewController {
return
}
}
-
+
// 记录操作到日志
// Record operation to log
var sourceFiles = items.compactMap { item -> String? in
guard let fileURL = URL(string: item.string(forType: .fileURL) ?? "") else { return nil }
return fileURL.lastPathComponent
}
-
+
let sourceFilesStr: String
if sourceFiles.count > 3 {
sourceFilesStr = sourceFiles[0...2].joined(separator: ", ") + "..."
} else {
sourceFilesStr = sourceFiles.joined(separator: ", ")
}
-
+
let operationLog = "[Move] \(sourceFilesStr) -> \(destinationURL.lastPathComponent)"
globalVar.operationLogs.append(operationLog)
-
+
+ let sourceURLs = items.compactMap { item -> URL? in
+ guard let value = item.string(forType: .fileURL) else { return nil }
+ return URL(string: value)
+ }
+ preserveViewportAnchorForMove(sourceURLs, folderPath: curFolder)
+ let unconflictedPlans = sourceURLs.compactMap { source -> (source: URL, destination: URL)? in
+ guard source.deletingLastPathComponent().standardizedFileURL != destinationURL.standardizedFileURL else {
+ return nil
+ }
+ let target = destinationURL.appendingPathComponent(source.lastPathComponent)
+ return (source: source, destination: target)
+ }
+ if !sourceURLs.isEmpty,
+ unconflictedPlans.count == sourceURLs.count,
+ !ifAutoRenameWhenDifferentSource,
+ allowBackgroundPreflight {
+ executeUnconflictedMovesAsync(
+ unconflictedPlans,
+ destinationURL: destinationURL,
+ originalFolderPath: curFolder,
+ pasteboard: pasteboard,
+ checkConflictsBeforeMoving: true
+ )
+ return
+ }
+
// 在文件操作期间抑制文件系统监控触发的刷新,操作完成后主动刷新
// Suppress FS watcher refreshes during file operations, refresh explicitly after completion
publicVar.isInFileOperation = true
@@ -747,45 +2766,43 @@ extension ViewController {
// Record successfully pasted destination paths for selection after refresh
var successfulDestURLs: [String] = []
var indexMovePairs: [(oldPath: String, newPath: String)] = []
+ let pasteboardChangeCount = pasteboard.changeCount
defer {
- publicVar.isInFileOperation = false
if !successfulDestURLs.isEmpty {
- triggerFinderSound()
- publicVar.filesForLocateAfterChange = successfulDestURLs
- publicVar.filesForLocateAfterChangeTime = .now()
- // 移动完成后清空通用剪贴板,防止再次粘贴时操作已不存在的源文件
- // Clear general pasteboard after move to prevent pasting non-existent source files
- if pasteboard === NSPasteboard.general {
- pasteboard.clearContents()
- }
- var ifRefresh = true
- if publicVar.isRecursiveMode || curFolder.hasPrefix("file:///VirtualFinderTagsFolder") {
- fileDB.lock()
- ifRefresh = fileDB.db[SortKeyDir(fileDB.curFolder)]?.files.count ?? 0 <= RESET_VIEW_FILE_NUM_THRESHOLD
- fileDB.unlock()
- }
- if ifRefresh {
- scheduledRefresh()
- }
+ finishMoveOperation(
+ successfulDestURLs: successfulDestURLs,
+ movePairs: indexMovePairs,
+ failedCount: 0,
+ destinationURL: destinationURL,
+ originalFolderPath: curFolder,
+ pasteboard: pasteboard,
+ pasteboardChangeCount: pasteboardChangeCount
+ )
+ } else {
+ publicVar.collectionViewportAnchorAfterRefresh = nil
}
if !indexMovePairs.isEmpty {
- EnhancedIndex.handleFilesMoved(indexMovePairs)
+ let pairs = indexMovePairs
+ DispatchQueue.global(qos: .utility).async {
+ EnhancedIndex.handleFilesMoved(pairs)
+ }
}
+ publicVar.isInFileOperation = false
}
-
+
var shouldReplaceAll = false
var shouldMergeAll = false
var shouldSkipAll = false
var shouldAutoRenameAll = false
let sharedMergeState = MergeConflictState()
-
+
let StoreIsKeyEventEnabled = publicVar.isKeyEventEnabled
publicVar.isKeyEventEnabled = false
for item in items {
guard let fileURL = URL(string: item.string(forType: .fileURL) ?? "") else { continue }
let prevSuccessCount = successfulDestURLs.count
var destURL = destinationURL.appendingPathComponent(fileURL.lastPathComponent)
-
+
// 如果是在同一目录移动,则不作动作
// If moving in same directory, do nothing
var isInSameFolder = fileURL.deletingLastPathComponent() == destinationURL
@@ -797,7 +2814,10 @@ extension ViewController {
destURL = getUniqueDestinationURL(for: destURL, isInPlace: false)
}
- if FileManager.default.fileExists(atPath: destURL.path) {
+ let targetExists = knownExistingTargetPaths?.contains(
+ destURL.standardizedFileURL.path.lowercased()
+ ) ?? FileManager.default.fileExists(atPath: destURL.path)
+ if targetExists {
// 检测源和目标是否都是文件夹
// Check if both source and destination are folders
var srcIsDir: ObjCBool = false
@@ -805,7 +2825,7 @@ extension ViewController {
var dstIsDir: ObjCBool = false
FileManager.default.fileExists(atPath: destURL.path, isDirectory: &dstIsDir)
let bothAreFolders = srcIsDir.boolValue && dstIsDir.boolValue
-
+
if shouldReplaceAll {
do {
try FileManager.default.removeItem(at: destURL)
@@ -922,21 +2942,21 @@ extension ViewController {
}
publicVar.isKeyEventEnabled = StoreIsKeyEventEnabled
}
-
+
func handleDelete(fileUrls: [URL] = [], isShowPrompt: Bool = true) -> Bool {
var urls = fileUrls
if urls.count == 0 {
urls = publicVar.selectedUrls()
}
guard urls.count != 0 else {return false}
-
+
fileDB.lock()
let curFolder = fileDB.curFolder
fileDB.unlock()
-
+
let ifHasPermission = requestAppleEventsPermission()
let isShiftPressed = isShiftKeyPressed()
-
+
let alert = NSAlert()
alert.messageText = NSLocalizedString("Delete", comment: "删除")
if isShiftPressed {
@@ -977,7 +2997,7 @@ extension ViewController {
// User confirmed deletion
let fileManager = FileManager.default
var urlsToDelete = [URL]()
-
+
for url in urls {
if fileManager.fileExists(atPath: url.path) {
urlsToDelete.append(url)
@@ -985,23 +3005,23 @@ extension ViewController {
log("File does not exist: \(url.path)")
}
}
-
+
// 记录操作到日志
// Record operation to log
var sourceFiles = urlsToDelete.map { url -> String in
return url.lastPathComponent
}
-
+
let sourceFilesStr: String
if sourceFiles.count > 3 {
sourceFilesStr = sourceFiles[0...2].joined(separator: ", ") + "..."
} else {
sourceFilesStr = sourceFiles.joined(separator: ", ")
}
-
+
let operationLog = "[Delete] \(sourceFilesStr)"
globalVar.operationLogs.append(operationLog)
-
+
if !urlsToDelete.isEmpty {
// 永久删除
// Permanently delete
@@ -1017,18 +3037,18 @@ extension ViewController {
let escapedPath = url.path.replacingOccurrences(of: "\"", with: "\\\"")
appleScriptURLs += "\"\(escapedPath)\" as POSIX file, "
}
-
+
// Remove the trailing comma and space
if appleScriptURLs.hasSuffix(", ") {
appleScriptURLs = String(appleScriptURLs.dropLast(2))
}
-
+
let script = """
tell application "Finder"
move { \(appleScriptURLs) } to trash
end tell
"""
-
+
var error: NSDictionary?
if let scriptObject = NSAppleScript(source: script) {
scriptObject.executeAndReturnError(&error)
@@ -1048,7 +3068,7 @@ extension ViewController {
}
}
}
-
+
EnhancedIndex.handleFilesDeleted(urlsToDelete.map { $0.path })
// 文件更改计数
@@ -1058,7 +3078,7 @@ extension ViewController {
// 手动刷新
// Manually refresh
var ifRefresh = true
- if publicVar.isRecursiveMode || curFolder.hasPrefix("file:///VirtualFinderTagsFolder") {
+ if publicVar.isRecursiveMode || isVirtualFolderPath(curFolder) {
fileDB.lock()
ifRefresh = fileDB.db[SortKeyDir(fileDB.curFolder)]?.files.count ?? 0 <= RESET_VIEW_FILE_NUM_THRESHOLD
fileDB.unlock()
@@ -1066,7 +3086,7 @@ extension ViewController {
if ifRefresh {
scheduledRefresh()
}
-
+
} else {
log("File to delete does not exist")
}
@@ -1078,7 +3098,7 @@ extension ViewController {
return false
}
}
-
+
enum ReplaceDialogUserChoice {
case replace
case replaceAll
@@ -1097,7 +3117,7 @@ extension ViewController {
var dstIsDir: ObjCBool = false
let destIsFolder = FileManager.default.fileExists(atPath: url.path, isDirectory: &dstIsDir) && dstIsDir.boolValue
let canMerge = sourceIsFolder && destIsFolder
-
+
let alert = NSAlert()
alert.messageText = String(format: NSLocalizedString("has-exist-in-dest", comment: "目标文件夹中已存在名为xx的文件。"), url.lastPathComponent)
if isMove {
@@ -1107,7 +3127,7 @@ extension ViewController {
}
alert.alertStyle = .warning
alert.icon = NSImage(named: NSImage.infoName)
-
+
// Button order: Replace, [Merge if both folders], Auto Rename, [Skip if multiple], Cancel
alert.addButton(withTitle: NSLocalizedString("Replace", comment: "替换"))
if canMerge {
@@ -1118,15 +3138,15 @@ extension ViewController {
alert.addButton(withTitle: NSLocalizedString("Skip", comment: "跳过"))
}
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "取消"))
-
+
let applyToAllCheckbox = NSButton(checkboxWithTitle: NSLocalizedString("Apply to all", comment: "应用到全部"), target: nil, action: nil)
if !isSingle {
alert.accessoryView = applyToAllCheckbox
}
-
+
let response = alert.runModal()
let applyToAll = applyToAllCheckbox.state == .on
-
+
if canMerge {
// Buttons: Replace(1000), Merge(1001), AutoRename(1002), Skip?(1003), Cancel(1003 or 1004)
switch response {
@@ -1159,7 +3179,7 @@ extension ViewController {
}
}
}
-
+
/// Tracks user choices across recursive merge operations so "apply to all" persists.
class MergeConflictState {
var shouldReplaceAll = false
@@ -1167,17 +3187,17 @@ extension ViewController {
var shouldAutoRenameAll = false
var cancelled = false
}
-
+
@discardableResult
func mergeFolderByCopy(from sourceURL: URL, to destURL: URL, state: MergeConflictState? = nil) -> Bool {
let fm = FileManager.default
let state = state ?? MergeConflictState()
-
+
var isDir: ObjCBool = false
guard fm.fileExists(atPath: sourceURL.path, isDirectory: &isDir), isDir.boolValue else {
return false
}
-
+
if !fm.fileExists(atPath: destURL.path) {
do {
try fm.copyItem(at: sourceURL, to: destURL)
@@ -1187,22 +3207,22 @@ extension ViewController {
return false
}
}
-
+
guard let contents = try? fm.contentsOfDirectory(at: sourceURL, includingPropertiesForKeys: [.isDirectoryKey], options: []) else {
return false
}
-
+
var allSuccess = true
for itemURL in contents {
if state.cancelled { return false }
-
+
var destItemURL = destURL.appendingPathComponent(itemURL.lastPathComponent)
-
+
var srcIsDir: ObjCBool = false
fm.fileExists(atPath: itemURL.path, isDirectory: &srcIsDir)
var dstIsDir: ObjCBool = false
let destExists = fm.fileExists(atPath: destItemURL.path, isDirectory: &dstIsDir)
-
+
if srcIsDir.boolValue && destExists && dstIsDir.boolValue {
if !mergeFolderByCopy(from: itemURL, to: destItemURL, state: state) {
allSuccess = false
@@ -1307,17 +3327,17 @@ extension ViewController {
}
return allSuccess
}
-
+
@discardableResult
func mergeFolderByMove(from sourceURL: URL, to destURL: URL, state: MergeConflictState? = nil) -> Bool {
let fm = FileManager.default
let state = state ?? MergeConflictState()
-
+
var isDir: ObjCBool = false
guard fm.fileExists(atPath: sourceURL.path, isDirectory: &isDir), isDir.boolValue else {
return false
}
-
+
if !fm.fileExists(atPath: destURL.path) {
do {
try fm.moveItem(at: sourceURL, to: destURL)
@@ -1327,22 +3347,22 @@ extension ViewController {
return false
}
}
-
+
guard let contents = try? fm.contentsOfDirectory(at: sourceURL, includingPropertiesForKeys: [.isDirectoryKey], options: []) else {
return false
}
-
+
var allSuccess = true
for itemURL in contents {
if state.cancelled { return false }
-
+
var destItemURL = destURL.appendingPathComponent(itemURL.lastPathComponent)
-
+
var srcIsDir: ObjCBool = false
fm.fileExists(atPath: itemURL.path, isDirectory: &srcIsDir)
var dstIsDir: ObjCBool = false
let destExists = fm.fileExists(atPath: destItemURL.path, isDirectory: &dstIsDir)
-
+
if srcIsDir.boolValue && destExists && dstIsDir.boolValue {
if !mergeFolderByMove(from: itemURL, to: destItemURL, state: state) {
allSuccess = false
@@ -1445,23 +3465,23 @@ extension ViewController {
}
}
}
-
+
// Remove source directory if it's now empty or all items were moved
let remaining = try? fm.contentsOfDirectory(at: sourceURL, includingPropertiesForKeys: nil, options: [])
if remaining?.isEmpty ?? true {
try? fm.removeItem(at: sourceURL)
}
-
+
return allSuccess
}
-
+
func handleRename(urls: [URL]) -> Bool {
if urls.isEmpty { return false }
fileDB.lock()
let curFolder = fileDB.curFolder
fileDB.unlock()
-
+
// 创建一个警告对话框
// Create an alert dialog
let alert = NSAlert()
@@ -1473,7 +3493,7 @@ extension ViewController {
// 设置系统通知图标
// Set system notification icon
alert.icon = NSImage(named: NSImage.infoName)
-
+
// 添加一个文本输入框到警告对话框中
// Add a text input field to the alert dialog
let inputTextField = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 24))
@@ -1484,7 +3504,7 @@ extension ViewController {
textFieldCell.isScrollable = true
}
alert.accessoryView = inputTextField
-
+
// 显示对话框
// Show dialog
let StoreIsKeyEventEnabled = publicVar.isKeyEventEnabled
@@ -1494,7 +3514,7 @@ extension ViewController {
// Check if it's a folder
var isDirectory: ObjCBool = false
FileManager.default.fileExists(atPath: urls[0].path, isDirectory: &isDirectory)
-
+
_ = inputTextField.becomeFirstResponder()
if isDirectory.boolValue {
// 如果是文件夹,选中全部内容
@@ -1510,20 +3530,13 @@ extension ViewController {
let response = alert.runModal()
publicVar.isKeyEventEnabled = StoreIsKeyEventEnabled
- // 在文件操作期间抑制文件系统监控触发的刷新,操作完成后主动刷新
- // Suppress FS watcher refreshes during file operations, refresh explicitly after completion
- publicVar.isInFileOperation = true
- defer {
- publicVar.isInFileOperation = false
- }
-
// 根据用户的选择处理结果
// Process result based on user's choice
// OK按钮
// OK button
if response == .alertFirstButtonReturn {
let newBaseName = inputTextField.stringValue
-
+
if newBaseName != "" {
// 记录操作到日志
@@ -1531,24 +3544,22 @@ extension ViewController {
let sourceFiles = urls.map { url -> String in
return url.lastPathComponent
}
-
+
let sourceFilesStr: String
if sourceFiles.count > 3 {
sourceFilesStr = sourceFiles[0...2].joined(separator: ", ") + "..."
} else {
sourceFilesStr = sourceFiles.joined(separator: ", ")
}
-
+
let operationLog = "[Rename] \(sourceFilesStr) -> \(newBaseName)"
globalVar.operationLogs.append(operationLog)
- var allSuccess = true
-
// 第一步:生成最终目标名字列表
// Step 1: Generate final target name list
- var finalNames: [(originalUrl: URL, finalUrl: URL)] = []
+ var finalNames: [FileRenameMapping] = []
var nameIndex = 1
-
+
for originalUrl in urls {
var newName = newBaseName
// 批量重命名
@@ -1567,21 +3578,21 @@ extension ViewController {
}
newUrl = originalUrl.deletingLastPathComponent().appendingPathComponent(newName)
nameIndex += 1
-
+
// 检查是否存在同名文件,但排除当前待重命名列表中的文件
// Check if file with same name exists, but exclude files in current rename list
if FileManager.default.fileExists(atPath: newUrl.path) &&
!urls.contains(where: { $0.path.lowercased() == newUrl.path.lowercased() })
{
collision = true
-
+
let alert = NSAlert()
alert.messageText = NSLocalizedString("File Already Exists", comment: "文件已存在")
alert.informativeText = NSLocalizedString("file-exists-continue-batch-rename", comment: "批量重命名的序号与已有文件重名,是否继续?")
alert.alertStyle = .warning
alert.addButton(withTitle: NSLocalizedString("Continue", comment: "继续"))
alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "取消"))
-
+
if alert.runModal() == .alertSecondButtonReturn {
return false
}
@@ -1603,81 +3614,448 @@ extension ViewController {
let isCaseOnlyRename = originalUrl.path.lowercased() == newUrl.path.lowercased()
if FileManager.default.fileExists(atPath: newUrl.path) && !isCaseOnlyRename {
showAlert(message: NSLocalizedString("renaming-conflict", comment: "该名称的文件已存在,请选择其他名称。"))
- allSuccess = false
return false
}
}
-
+
let finalUrl = originalUrl.deletingLastPathComponent().appendingPathComponent(newName)
- finalNames.append((originalUrl: originalUrl, finalUrl: finalUrl))
- }
-
- // 第二步:将所有文件改成临时文件名
- // Step 2: Rename all files to temporary names
- var tempNames: [(tempUrl: URL, finalUrl: URL)] = []
- for (index, item) in finalNames.enumerated() {
- let tempName = "temp_rename_\(UUID().uuidString)"
- let tempUrl = item.originalUrl.deletingLastPathComponent().appendingPathComponent(tempName)
-
- do {
- try FileManager.default.moveItem(at: item.originalUrl, to: tempUrl)
- tempNames.append((tempUrl: tempUrl, finalUrl: item.finalUrl))
- } catch {
- // 如果临时重命名失败,回滚之前的临时重命名
- // If temporary rename fails, rollback previous temporary renames
- for prevTemp in tempNames {
- try? FileManager.default.moveItem(at: prevTemp.tempUrl, to: finalNames[tempNames.count].originalUrl)
- }
- log("Failed to create temp name: \(error)", level: .error)
- allSuccess = false
- break
- }
+ finalNames.append(FileRenameMapping(from: originalUrl, to: finalUrl))
}
-
- // 第三步:将临时文件名改成最终文件名
- // Step 3: Rename temporary files to final names
- if allSuccess {
- for item in tempNames {
- do {
- // 文件更改计数
- // File change count
- publicVar.fileChangedCount += 1
-
- try FileManager.default.moveItem(at: item.tempUrl, to: item.finalUrl)
- log("File renamed to \(item.finalUrl.lastPathComponent)")
- } catch {
- log("Failed to rename file: \(error)", level: .error)
- allSuccess = false
- // 这里不需要回滚,因为用户可以通过临时文件找回
- // No need to rollback here, as user can recover through temporary files
- break
- }
+
+ let actionName = urls.count > 1 ? NSLocalizedString("批量重命名", comment: "batch rename undo") : NSLocalizedString("重命名", comment: "rename undo")
+ let renameResult = executeFileRenameMappings(
+ finalNames,
+ actionName: actionName,
+ inPlaceFolderPath: curFolder
+ )
+ if renameResult {
+ for item in finalNames {
+ log("File renamed to \(item.to.lastPathComponent)")
}
}
-
- if allSuccess && !finalNames.isEmpty {
- EnhancedIndex.handleFilesMoved(finalNames.map { (oldPath: $0.originalUrl.path, newPath: $0.finalUrl.path) })
+ return renameResult
+ }
+ }
+ return false
+ }
+
+ func handleBatchRenameFolders(urls: [URL]) -> Bool {
+ let fileManager = FileManager.default
+ let folders = urls.filter { url in
+ var isDirectory: ObjCBool = false
+ return !isReadOnlyVirtualFolderPath(url.absoluteString) &&
+ !isVirtualArchiveEntryPath(url.absoluteString) &&
+ fileManager.fileExists(atPath: url.path, isDirectory: &isDirectory) &&
+ isDirectory.boolValue
+ }
+ guard folders.count > 1, folders.count == urls.count else {
+ showAlert(message: NSLocalizedString("Please select at least two folders.", comment: "请至少选择两个文件夹。"))
+ return false
+ }
+
+ let normalizedPaths = folders.map { $0.standardizedFileURL.path + "/" }
+ for (index, path) in normalizedPaths.enumerated() {
+ if normalizedPaths.enumerated().contains(where: { otherIndex, otherPath in
+ otherIndex != index && otherPath.hasPrefix(path)
+ }) {
+ showAlert(message: NSLocalizedString("A parent folder and its subfolder cannot be renamed together.", comment: "不能同时重命名父文件夹及其子文件夹。"))
+ return false
+ }
+ }
+
+ let alert = NSAlert()
+ alert.messageText = NSLocalizedString("Batch Rename Folders", comment: "批量重命名文件夹")
+ alert.informativeText = NSLocalizedString("Rules are applied in this order: replace, format, prefix, suffix.", comment: "规则应用顺序:替换、格式化、前缀、后缀。")
+ alert.alertStyle = .informational
+ alert.addButton(withTitle: NSLocalizedString("Preview", comment: "预览"))
+ alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "取消"))
+
+ let form = NSView(frame: NSRect(x: 0, y: 0, width: 440, height: 166))
+ let rows: [(String, String, String)] = [
+ (NSLocalizedString("Prefix", comment: "前缀"), "", NSLocalizedString("Optional text before the name", comment: "名称前的可选文本")),
+ (NSLocalizedString("Suffix", comment: "后缀"), "", NSLocalizedString("Optional text after the name", comment: "名称后的可选文本")),
+ (NSLocalizedString("Find", comment: "查找"), "", NSLocalizedString("Text to replace", comment: "要替换的文本")),
+ (NSLocalizedString("Replace With", comment: "替换为"), "", NSLocalizedString("Leave empty to remove matches", comment: "留空可删除匹配文本")),
+ (NSLocalizedString("Format", comment: "格式化"), "{name}", "{name}, {index}, {index:03}")
+ ]
+ var fields: [NSTextField] = []
+ for (index, row) in rows.enumerated() {
+ let y = 136 - CGFloat(index * 33)
+ let label = NSTextField(labelWithString: row.0)
+ label.frame = NSRect(x: 0, y: y + 2, width: 100, height: 22)
+ label.alignment = .right
+ let field = NSTextField(frame: NSRect(x: 108, y: y, width: 332, height: 24))
+ field.stringValue = row.1
+ field.placeholderString = row.2
+ form.addSubview(label)
+ form.addSubview(field)
+ fields.append(field)
+ }
+ alert.accessoryView = form
+
+ let storedKeyState = publicVar.isKeyEventEnabled
+ publicVar.isKeyEventEnabled = false
+ defer { publicVar.isKeyEventEnabled = storedKeyState }
+ guard alert.runModal() == .alertFirstButtonReturn else { return false }
+
+ let prefix = fields[0].stringValue
+ let suffix = fields[1].stringValue
+ let findText = fields[2].stringValue
+ let replacement = fields[3].stringValue
+ let format = fields[4].stringValue.isEmpty ? "{name}" : fields[4].stringValue
+ let sourcePathSet = Set(folders.map { $0.path.lowercased() })
+ var targetPathSet = Set()
+ var mappings: [FileRenameMapping] = []
+
+ for (offset, folder) in folders.enumerated() {
+ var name = folder.lastPathComponent
+ if !findText.isEmpty {
+ name = name.replacingOccurrences(of: findText, with: replacement)
+ }
+ var formatted = format.replacingOccurrences(of: "{name}", with: name)
+ formatted = formatted.replacingOccurrences(of: "{index}", with: "\(offset + 1)")
+ if let regex = try? NSRegularExpression(pattern: #"\{index:0?(\d+)\}"#) {
+ let matches = regex.matches(in: formatted, range: NSRange(formatted.startIndex..., in: formatted))
+ for match in matches.reversed() {
+ guard let widthRange = Range(match.range(at: 1), in: formatted),
+ let tokenRange = Range(match.range, in: formatted),
+ let width = Int(formatted[widthRange]) else { continue }
+ let value = String(format: "%0*d", width, offset + 1)
+ formatted.replaceSubrange(tokenRange, with: value)
+ }
+ }
+ let newName = (prefix + formatted + suffix).trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !newName.isEmpty, newName != ".", newName != "..", !newName.contains("/") else {
+ showAlert(message: String(format: NSLocalizedString("Invalid folder name: %@", comment: "无效的文件夹名称:%@"), newName))
+ return false
+ }
+
+ let target = folder.deletingLastPathComponent().appendingPathComponent(newName, isDirectory: true)
+ let targetPath = target.path.lowercased()
+ guard targetPathSet.insert(targetPath).inserted else {
+ showAlert(message: String(format: NSLocalizedString("Duplicate target name: %@", comment: "目标名称重复:%@"), newName))
+ return false
+ }
+ if fileManager.fileExists(atPath: target.path) && !sourcePathSet.contains(targetPath) {
+ showAlert(message: String(format: NSLocalizedString("A file or folder already exists: %@", comment: "文件或文件夹已存在:%@"), newName))
+ return false
+ }
+ mappings.append(FileRenameMapping(from: folder, to: target))
+ }
+
+ let changedMappings = mappings.filter { $0.from.path != $0.to.path }
+ guard !changedMappings.isEmpty else {
+ showAlert(message: NSLocalizedString("The rules do not change any folder names.", comment: "这些规则未改变任何文件夹名称。"))
+ return false
+ }
+
+ let preview = NSAlert()
+ preview.messageText = NSLocalizedString("Confirm Batch Rename", comment: "确认批量重命名")
+ preview.informativeText = String(format: NSLocalizedString("%d folders will be renamed.", comment: "将重命名 %d 个文件夹。"), changedMappings.count)
+ preview.alertStyle = .warning
+ preview.addButton(withTitle: NSLocalizedString("Rename", comment: "重命名"))
+ preview.addButton(withTitle: NSLocalizedString("Cancel", comment: "取消"))
+ let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 480, height: min(260, CGFloat(changedMappings.count * 24 + 12))))
+ scrollView.hasVerticalScroller = true
+ scrollView.borderType = .bezelBorder
+ let textView = NSTextView(frame: scrollView.bounds)
+ textView.isEditable = false
+ textView.isSelectable = true
+ textView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular)
+ textView.string = changedMappings.map { "\($0.from.lastPathComponent) -> \($0.to.lastPathComponent)" }.joined(separator: "\n")
+ scrollView.documentView = textView
+ preview.accessoryView = scrollView
+ guard preview.runModal() == .alertFirstButtonReturn else { return false }
+
+ globalVar.operationLogs.append("[BatchRenameFolders] \(changedMappings.count) folders")
+ fileDB.lock()
+ let inPlaceFolderPath = fileDB.curFolder
+ fileDB.unlock()
+ return executeFileRenameMappings(
+ changedMappings,
+ actionName: NSLocalizedString("Batch Rename Folders", comment: "批量重命名文件夹"),
+ inPlaceFolderPath: inPlaceFolderPath
+ )
+ }
+
+ /// Shows a rename toolbox for any multi-selection, preserving file extensions by default.
+ func handleBatchRenameSelectedItems(urls: [URL]) -> Bool {
+ let fileManager = FileManager.default
+ var seenPaths = Set()
+ let items = urls.filter { url in
+ guard !isReadOnlyVirtualFolderPath(url.absoluteString),
+ !isVirtualArchiveEntryPath(url.absoluteString),
+ fileManager.fileExists(atPath: url.path) else {
+ return false
+ }
+ return seenPaths.insert(url.standardizedFileURL.path.lowercased()).inserted
+ }
+
+ guard items.count > 1 else {
+ showAlert(message: "请至少选择两个可重命名的文件或文件夹。")
+ return false
+ }
+
+ // A selected parent cannot be renamed together with one of its selected children.
+ let selectedPaths = items.map { $0.standardizedFileURL.path }
+ let directoryPaths = Set(items.compactMap { item -> String? in
+ var isDirectory: ObjCBool = false
+ guard fileManager.fileExists(atPath: item.path, isDirectory: &isDirectory), isDirectory.boolValue else {
+ return nil
+ }
+ return item.standardizedFileURL.path
+ })
+ for folderPath in directoryPaths {
+ let folderPrefix = folderPath + "/"
+ if selectedPaths.contains(where: { $0 != folderPath && $0.hasPrefix(folderPrefix) }) {
+ showAlert(message: "不能同时重命名父文件夹及其已选中的子项目。")
+ return false
+ }
+ }
+
+ let alert = NSAlert()
+ alert.messageText = "批量重命名所选项目"
+ alert.informativeText = "按“替换 → 格式 → 前缀 → 后缀”的顺序处理。默认保留文件扩展名。"
+ alert.alertStyle = .informational
+ alert.addButton(withTitle: "预览")
+ alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "取消"))
+
+ let form = NSView(frame: NSRect(x: 0, y: 0, width: 480, height: 202))
+ let rows: [(String, String, String)] = [
+ ("前缀", "", "添加在名称前(扩展名之前)"),
+ ("后缀", "", "添加在名称后(扩展名之前)"),
+ ("查找", "", "在原文件名中查找的文字"),
+ ("替换为", "", "留空可删除匹配内容"),
+ ("格式", "{name}", "变量:{name}、{index}、{index:03}、{folder}、{ext}")
+ ]
+ var fields: [NSTextField] = []
+ for (index, row) in rows.enumerated() {
+ let y = 166 - CGFloat(index * 32)
+ let label = NSTextField(labelWithString: row.0)
+ label.frame = NSRect(x: 0, y: y + 2, width: 100, height: 22)
+ label.alignment = .right
+ let field = NSTextField(frame: NSRect(x: 108, y: y, width: 372, height: 24))
+ field.stringValue = row.1
+ field.placeholderString = row.2
+ form.addSubview(label)
+ form.addSubview(field)
+ fields.append(field)
+ }
+ let hint = NSTextField(wrappingLabelWithString: "{name} 为原名称(不含扩展名);{index:03} 可补零。格式中包含 {ext} 时由格式决定完整文件名,例如 {name}_{index}.{ext}。")
+ hint.frame = NSRect(x: 108, y: 0, width: 372, height: 36)
+ hint.font = NSFont.systemFont(ofSize: 11)
+ hint.textColor = .secondaryLabelColor
+ form.addSubview(hint)
+ alert.accessoryView = form
+
+ let storedKeyState = publicVar.isKeyEventEnabled
+ publicVar.isKeyEventEnabled = false
+ defer { publicVar.isKeyEventEnabled = storedKeyState }
+ guard alert.runModal() == .alertFirstButtonReturn else { return false }
+
+ let prefix = fields[0].stringValue
+ let suffix = fields[1].stringValue
+ let findText = fields[2].stringValue
+ let replacement = fields[3].stringValue
+ let format = fields[4].stringValue.isEmpty ? "{name}" : fields[4].stringValue
+ let formatControlsExtension = format.contains("{ext}")
+ let sourcePathSet = Set(items.map { $0.path.lowercased() })
+ var targetPathSet = Set()
+ var mappings: [FileRenameMapping] = []
+
+ for (offset, item) in items.enumerated() {
+ let index = offset + 1
+ var baseName = item.deletingPathExtension().lastPathComponent
+ if !findText.isEmpty {
+ baseName = baseName.replacingOccurrences(of: findText, with: replacement)
+ }
+
+ let parentName = item.deletingLastPathComponent().lastPathComponent
+ var formatted = format
+ .replacingOccurrences(of: "{name}", with: baseName)
+ .replacingOccurrences(of: "{folder}", with: parentName)
+ .replacingOccurrences(of: "{ext}", with: item.pathExtension)
+ .replacingOccurrences(of: "{index}", with: "\(index)")
+ if let regex = try? NSRegularExpression(pattern: #"\{index:0?(\d+)\}"#) {
+ let matches = regex.matches(in: formatted, range: NSRange(formatted.startIndex..., in: formatted))
+ for match in matches.reversed() {
+ guard let widthRange = Range(match.range(at: 1), in: formatted),
+ let tokenRange = Range(match.range, in: formatted),
+ let width = Int(formatted[widthRange]) else { continue }
+ formatted.replaceSubrange(tokenRange, with: String(format: "%0*d", width, index))
}
+ }
- // 手动刷新
- // Manually refresh
- var ifRefresh = true
- if publicVar.isRecursiveMode || curFolder.hasPrefix("file:///VirtualFinderTagsFolder") {
- fileDB.lock()
- ifRefresh = fileDB.db[SortKeyDir(fileDB.curFolder)]?.files.count ?? 0 <= RESET_VIEW_FILE_NUM_THRESHOLD
- fileDB.unlock()
-
+ var newName = (prefix + formatted + suffix).trimmingCharacters(in: .whitespacesAndNewlines)
+ if !formatControlsExtension, !item.pathExtension.isEmpty {
+ newName += ".\(item.pathExtension)"
+ }
+ guard !newName.isEmpty, newName != ".", newName != "..", !newName.contains("/") else {
+ showAlert(message: "无效的目标名称:\(newName)")
+ return false
+ }
+
+ let target = item.deletingLastPathComponent().appendingPathComponent(
+ newName,
+ isDirectory: directoryPaths.contains(item.standardizedFileURL.path)
+ )
+ let targetPath = target.path.lowercased()
+ guard targetPathSet.insert(targetPath).inserted else {
+ showAlert(message: "目标名称重复:\(newName)")
+ return false
+ }
+ if fileManager.fileExists(atPath: target.path) && !sourcePathSet.contains(targetPath) {
+ showAlert(message: "已有同名文件或文件夹:\(newName)")
+ return false
+ }
+ mappings.append(FileRenameMapping(from: item, to: target))
+ }
+
+ let changedMappings = mappings.filter { $0.from.path != $0.to.path }
+ guard !changedMappings.isEmpty else {
+ showAlert(message: "这些规则未改变任何名称。")
+ return false
+ }
+
+ let preview = NSAlert()
+ preview.messageText = "确认批量重命名"
+ preview.informativeText = "将重命名 \(changedMappings.count) 个所选项目。"
+ preview.alertStyle = .warning
+ preview.addButton(withTitle: NSLocalizedString("Rename", comment: "重命名"))
+ preview.addButton(withTitle: NSLocalizedString("Cancel", comment: "取消"))
+ let previewHeight = min(260, max(92, CGFloat(changedMappings.count * 24 + 26)))
+ let scrollView = NSScrollView(frame: NSRect(x: 0, y: 0, width: 500, height: previewHeight))
+ scrollView.hasVerticalScroller = true
+ scrollView.borderType = .bezelBorder
+ let tableView = NSTableView(frame: NSRect(x: 0, y: 0, width: 500, height: previewHeight))
+ tableView.headerView = NSTableHeaderView()
+ tableView.rowHeight = 24
+ tableView.usesAlternatingRowBackgroundColors = true
+ tableView.gridStyleMask = [.solidHorizontalGridLineMask, .solidVerticalGridLineMask]
+
+ let originalColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("original"))
+ originalColumn.title = "原名称"
+ originalColumn.width = 250
+ originalColumn.minWidth = 140
+ let renamedColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("renamed"))
+ renamedColumn.title = "新名称"
+ renamedColumn.width = 250
+ renamedColumn.minWidth = 140
+ tableView.addTableColumn(originalColumn)
+ tableView.addTableColumn(renamedColumn)
+
+ let previewDataSource = BatchRenamePreviewDataSource(
+ mappings: changedMappings.map { ($0.from.lastPathComponent, $0.to.lastPathComponent) }
+ )
+ tableView.dataSource = previewDataSource
+ tableView.delegate = previewDataSource
+ scrollView.documentView = tableView
+ preview.accessoryView = scrollView
+ guard preview.runModal() == .alertFirstButtonReturn else { return false }
+
+ globalVar.operationLogs.append("[BatchRenameSelected] \(changedMappings.count) items")
+ fileDB.lock()
+ let inPlaceFolderPath = fileDB.curFolder
+ fileDB.unlock()
+ return executeFileRenameMappings(
+ changedMappings,
+ actionName: "批量重命名",
+ inPlaceFolderPath: inPlaceFolderPath
+ )
+ }
+
+ func handleQuickRenameInCurrentFolder() -> Bool {
+ guard !publicVar.isInFileOperation else { return false }
+
+ fileDB.lock()
+ let curFolder = fileDB.curFolder
+ let keys: [(SortKeyFile, FileModel)]
+ if let dirModel = fileDB.db[SortKeyDir(curFolder)] {
+ keys = getMapKeysFile(dirModel.files)
+ } else {
+ keys = []
+ }
+ fileDB.unlock()
+
+ let urls: [URL] = keys.compactMap { (_, file) in
+ guard !file.isDir else { return nil }
+ return URL(string: file.path)
+ }
+
+ if urls.isEmpty {
+ showAlert(message: NSLocalizedString("No files to rename in current folder.", comment: "当前目录没有可重命名的文件。"))
+ return false
+ }
+
+ let folderName: String = {
+ guard let folderURL = URL(string: curFolder) else { return "Folder" }
+ let name = folderURL.lastPathComponent.trimmingCharacters(in: .whitespacesAndNewlines)
+ return name.isEmpty ? "Folder" : (name.removingPercentEncoding ?? name)
+ }()
+
+ let rule = {
+ let trimmed = globalVar.quickRenameRule.trimmingCharacters(in: .whitespacesAndNewlines)
+ return trimmed.isEmpty ? "{folder}_{index}" : trimmed
+ }()
+
+ let operationLog = "[QuickRename] \(folderName) -> \(rule)"
+ globalVar.operationLogs.append(operationLog)
+ publicVar.collectionScrollRestoreAfterRefresh = nil
+ if let clipView = collectionView.enclosingScrollView?.contentView {
+ publicVar.collectionScrollRestoreAfterRefresh = (curFolder, clipView.bounds.origin)
+ }
+ publicVar.isInFileOperation = true
+ coreAreaView.showOperationIndeterminate("正在生成重命名方案…")
+
+ DispatchQueue.global(qos: .userInitiated).async { [weak self] in
+ guard let self = self else { return }
+ let originalPathSet = Set(urls.map { $0.path.lowercased() })
+ var plannedPathSet = Set()
+ var mappings: [FileRenameMapping] = []
+
+ for (idx, originalURL) in urls.enumerated() {
+ let index = idx + 1
+ var baseName = rule
+ .replacingOccurrences(of: "{folder}", with: folderName)
+ .replacingOccurrences(of: "{index}", with: "\(index)")
+
+ baseName = baseName.trimmingCharacters(in: .whitespacesAndNewlines)
+ if baseName.isEmpty {
+ baseName = "\(folderName)_\(index)"
}
- if ifRefresh {
- scheduledRefresh()
+
+ let ext = originalURL.pathExtension
+ var suffix = 1
+ var finalURL = originalURL
+ while true {
+ let candidateBase = suffix == 1 ? baseName : "\(baseName)_\(suffix)"
+ let candidateName = ext.isEmpty ? candidateBase : "\(candidateBase).\(ext)"
+ let candidateURL = originalURL.deletingLastPathComponent().appendingPathComponent(candidateName)
+ let candidatePath = candidateURL.path.lowercased()
+ let existsOutsideSelection = FileManager.default.fileExists(atPath: candidateURL.path) &&
+ !originalPathSet.contains(candidatePath)
+
+ if !existsOutsideSelection && plannedPathSet.insert(candidatePath).inserted {
+ finalURL = candidateURL
+ break
+ }
+ suffix += 1
}
-
- return allSuccess
+ mappings.append(FileRenameMapping(from: originalURL, to: finalURL))
+ }
+
+ DispatchQueue.main.async { [weak self] in
+ self?.executeFileRenameMappingsAsync(
+ mappings,
+ actionName: NSLocalizedString("快速重命名", comment: "quick rename undo"),
+ locateTargets: [],
+ inPlaceFolderPath: curFolder
+ )
}
}
- return false
+ return true
}
-
+
func applyCutItemsDimEffect() {
for window in NSApp.windows {
guard let vc = window.contentViewController as? ViewController else { continue }
@@ -1689,7 +4067,7 @@ extension ViewController {
updateOutlineViewCutDimEffect(vc.outlineView)
}
}
-
+
func clearCutItemsDimEffect() {
let hadCutItems = !globalVar.cutItemPaths.isEmpty
globalVar.cutItemPaths.removeAll()
@@ -1705,7 +4083,7 @@ extension ViewController {
}
}
}
-
+
private func updateOutlineViewCutDimEffect(_ outlineView: CustomOutlineView) {
let visibleRange = outlineView.rows(in: outlineView.visibleRect)
for row in visibleRange.location..<(visibleRange.location + visibleRange.length) {
diff --git a/FlowVision/Sources/ViewControllerExtension/FileSystem.swift b/FlowVision/Sources/ViewControllerExtension/FileSystem.swift
index 89f7a4c0..7d472d6b 100644
--- a/FlowVision/Sources/ViewControllerExtension/FileSystem.swift
+++ b/FlowVision/Sources/ViewControllerExtension/FileSystem.swift
@@ -14,7 +14,36 @@ private class ScanCancelHandler: NSObject {
}
extension ViewController {
-
+ private func directMediaCounts(in folderURL: URL) -> (images: Int, videos: Int)? {
+ let options: FileManager.DirectoryEnumerationOptions = publicVar.isShowHiddenFile ? [] : [.skipsHiddenFiles]
+ guard let contents = try? FileManager.default.contentsOfDirectory(
+ at: folderURL,
+ includingPropertiesForKeys: [.isDirectoryKey, .isAliasFileKey],
+ options: options
+ ) else {
+ return nil
+ }
+
+ var imageCount = 0
+ var videoCount = 0
+ for fileURL in contents {
+ if ((try? fileURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false) {
+ continue
+ }
+ if ((try? fileURL.resourceValues(forKeys: [.isAliasFileKey]).isAliasFile) ?? false) {
+ continue
+ }
+
+ let ext = fileURL.pathExtension.lowercased()
+ if globalVar.HandledImageAndRawExtensions.contains(ext) {
+ imageCount += 1
+ } else if globalVar.HandledVideoExtensions.contains(ext) {
+ videoCount += 1
+ }
+ }
+ return (imageCount, videoCount)
+ }
+
func scanFiles(at folderURL: URL, contents: inout [URL], properties: [URLResourceKey]) {
let options: FileManager.DirectoryEnumerationOptions = publicVar.isShowHiddenFile ? [] : [.skipsHiddenFiles]
let enumerator = FileManager.default.enumerator(at: folderURL, includingPropertiesForKeys: properties, options: options, errorHandler: { (url, error) -> Bool in
@@ -97,7 +126,7 @@ extension ViewController {
let cancelled = isCancelled
lock.unlock()
if cancelled { break }
-
+
let isDirectory = (try? url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) ?? false
if !isDirectory || isRecursiveContainFolder {
lock.lock()
@@ -202,6 +231,131 @@ extension ViewController {
}
}
}
+
+ private func isArchiveExtension(_ ext: String) -> Bool {
+ let archiveExtensions: Set = ["zip", "cbz", "tar", "tgz", "tbz", "tbz2", "txz", "tar.gz", "tar.bz2", "tar.xz"]
+ return archiveExtensions.contains(ext.lowercased())
+ }
+
+ func isSupportedArchiveURL(_ url: URL) -> Bool {
+ let name = url.lastPathComponent.lowercased()
+ if name.hasSuffix(".tar.gz") || name.hasSuffix(".tar.bz2") || name.hasSuffix(".tar.xz") {
+ return true
+ }
+ return isArchiveExtension(url.pathExtension.lowercased())
+ }
+
+ func getArchiveVirtualFolderURL(_ archiveURL: URL) -> URL? {
+ let safePath = archiveURL.absoluteString.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? ""
+ if safePath.isEmpty { return nil }
+ return URL(string: "\(VIRTUAL_ARCHIVE_PREFIX)/\(safePath)/")
+ }
+
+ private func decodeArchiveURL(from virtualArchiveURL: URL) -> URL? {
+ return parseVirtualArchivePath(virtualArchiveURL.absoluteString)?.archiveURL
+ }
+
+ private func makeVirtualArchiveEntryURL(archiveURL: URL, entryPath: String) -> URL? {
+ guard let root = getArchiveVirtualFolderURL(archiveURL) else { return nil }
+ let encodedEntryPath = entryPath.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? entryPath
+ return URL(string: "\(root.absoluteString)\(encodedEntryPath)")
+ }
+
+ private func resolveArchiveImageEntries(for archiveURL: URL) -> [String] {
+ let archivePath = archiveURL.absoluteString
+ if let cached = archiveImageEntryCache[archivePath] {
+ return cached
+ }
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/bsdtar")
+ process.arguments = ["-tf", archiveURL.path]
+ let stdOut = Pipe()
+ let stdErr = Pipe()
+ process.standardOutput = stdOut
+ process.standardError = stdErr
+ do {
+ try process.run()
+ } catch {
+ log("Failed to list archive entries: \(error)", level: .error)
+ return []
+ }
+
+ // Read stdout first to avoid potential pipe blocking on very large listing output.
+ let outputData = stdOut.fileHandleForReading.readDataToEndOfFile()
+ process.waitUntilExit()
+
+ guard process.terminationStatus == 0 else {
+ if let errText = String(data: stdErr.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8), !errText.isEmpty {
+ log("Failed to list archive entries: \(errText)", level: .error)
+ }
+ return []
+ }
+
+ let output = String(data: outputData, encoding: .utf8) ?? ""
+ var entries: [String] = []
+ for rawLine in output.components(separatedBy: .newlines) {
+ let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
+ if line.isEmpty { continue }
+ let decodedLine = decodeBsdtarEscapedPath(line)
+ registerArchiveEntryPathAlias(archiveURL: archiveURL, displayPath: decodedLine, rawPath: line)
+ if decodedLine.hasSuffix("/") { continue }
+ let ext = URL(fileURLWithPath: decodedLine).pathExtension.lowercased()
+ if globalVar.HandledImageAndRawExtensions.contains(ext) {
+ entries.append(decodedLine)
+ }
+ }
+ entries.sort { $0.localizedStandardCompare($1) == .orderedAscending }
+ archiveImageEntryCache[archivePath] = entries
+ return entries
+ }
+
+ private func loadVirtualFolderContents(_ folderURL: URL) -> [URL] {
+ if folderURL.path == "/VirtualFinderTagsFolder" {
+ return []
+ }
+ if folderURL.path == "/VirtualFavoritesFolder" {
+ var result: [URL] = []
+ var seen = Set()
+ for favoritePath in globalVar.myFavoritesArray {
+ guard let url = URL(string: favoritePath) else { continue }
+ if seen.contains(url.absoluteString) { continue }
+ var isDirectory: ObjCBool = false
+ if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), isDirectory.boolValue {
+ seen.insert(url.absoluteString)
+ result.append(url)
+ }
+ }
+ return result
+ }
+ if folderURL.path == "/VirtualHistoryFolder" {
+ var result: [URL] = []
+ var seen = Set()
+ for historyPath in publicVar.folderStepStack {
+ guard let url = URL(string: historyPath) else { continue }
+ if isVirtualFolderPath(url.absoluteString) { continue }
+ if seen.contains(url.absoluteString) { continue }
+ var isDirectory: ObjCBool = false
+ if FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory), isDirectory.boolValue {
+ seen.insert(url.absoluteString)
+ result.append(url)
+ }
+ }
+ return result
+ }
+ if folderURL.path.hasPrefix("/VirtualArchiveFolder") {
+ guard let archiveURL = decodeArchiveURL(from: folderURL) else { return [] }
+ let entries = resolveArchiveImageEntries(for: archiveURL)
+ return entries.compactMap { makeVirtualArchiveEntryURL(archiveURL: archiveURL, entryPath: $0) }
+ }
+ return []
+ }
+
+ @discardableResult
+ func openArchiveAsVirtualFolder(_ archiveURL: URL) -> Bool {
+ guard let virtualURL = getArchiveVirtualFolderURL(archiveURL) else { return false }
+ switchDirByDirection(direction: .zero, dest: virtualURL.absoluteString, stackDeep: 0)
+ return true
+ }
func isExifSortTimeExceedCancel(folderURL: URL, imageCount: Int, videoCount: Int) -> Bool {
let networkTimeConsume: Double = Double(imageCount+videoCount)/10.0
@@ -269,12 +423,14 @@ extension ViewController {
}
}
dirURLCacheParameters = curDirURLCacheParameters
-
- isInSameDir = !publicVar.isRecursiveMode && !folderURL.path.hasPrefix("/VirtualFinderTagsFolder")
+
+ isInSameDir = !publicVar.isRecursiveMode && !isVirtualFolderPath(folderURL.absoluteString)
if dirURLCache.isEmpty {
if folderURL.path.hasPrefix("/VirtualFinderTagsFolder") {
let tagName = folderURL.lastPathComponent
scanVirtualFiles(at: folderURL, contents: &dirURLCache, properties: properties, tagName: tagName)
+ } else if isVirtualFolderPath(folderURL.absoluteString) {
+ dirURLCache = loadVirtualFolderContents(folderURL)
}else if publicVar.isRecursiveMode {
scanFiles(at: folderURL, contents: &dirURLCache, properties: properties)
}else{
@@ -351,6 +507,9 @@ extension ViewController {
// 过滤出目录列表(含指向目录的替身)
// Filter out directory list (including aliases pointing to directories)
var subFolders = contents.filter { url in
+ if isVirtualArchiveEntryPath(url.absoluteString) {
+ return false
+ }
if let isDirectoryResourceValue = try? url.resourceValues(forKeys: [.isDirectoryKey]),
isDirectoryResourceValue.isDirectory == true {
return true
@@ -377,6 +536,9 @@ extension ViewController {
var imageCount=0
var searchCount=0
var fileContents = contents.filter { url in
+ if isVirtualArchiveEntryPath(url.absoluteString) {
+ return true
+ }
guard let isDirectoryResourceValue = try? url.resourceValues(forKeys: [.isDirectoryKey]), let isDirectory = isDirectoryResourceValue.isDirectory else {
return false
}
@@ -394,13 +556,17 @@ extension ViewController {
for file in fileContents {
let aliasValues = try? file.resourceValues(forKeys: [.isAliasFileKey, .isSymbolicLinkKey])
let isAlias = aliasValues?.isAliasFile == true
+ let effectiveURL: URL
let effectiveExt: String
if isAlias, let resolved = try? URL(resolvingAliasFileAt: file) {
+ effectiveURL = resolved
effectiveExt = resolved.pathExtension.lowercased()
} else {
+ effectiveURL = file
effectiveExt = file.pathExtension.lowercased()
}
- if publicVar.HandledFileExtensions.contains(effectiveExt) || publicVar.isShowAllTypeFile {
+ let shouldShowArchive = globalVar.showArchiveFileType && isSupportedArchiveURL(effectiveURL)
+ if publicVar.HandledFileExtensions.contains(effectiveExt) || publicVar.isShowAllTypeFile || shouldShowArchive {
filesUrlInFolder.append(file)
}
// 不将替身文件统计为图像或视频
@@ -540,38 +706,46 @@ extension ViewController {
var addDate: Date?
var doNotActualRead = false
var finderTags: [String] = []
+ var childImageCount: Int?
+ var childVideoCount: Int?
do{
// 文件在前i个,目录在后面
// Files in first i items, directories after
if i < fileCount {
- let resourceValues = try filesUrlInFolder[i].resourceValues(forKeys: Set(properties))
- if let tmp = resourceValues.isAliasFile {
- isAlias=tmp
- }
- if let tmp = resourceValues.fileSize {
- fileSize=tmp
- fileSortKey.size=tmp
- }
- if let tmp = resourceValues.creationDate {
- createDate=tmp
- fileSortKey.createDate=tmp
- }
- if let tmp = resourceValues.contentModificationDate {
- modDate=tmp
- fileSortKey.modDate=tmp
- }
- if let tmp = resourceValues.addedToDirectoryDate {
- addDate=tmp
- fileSortKey.addDate=tmp
- }
- if let isUbiquitousItem = resourceValues.isUbiquitousItem,
- isUbiquitousItem,
- let downloadingStatus = resourceValues.ubiquitousItemDownloadingStatus,
- downloadingStatus != .current {
- doNotActualRead=true
+ let currentURL = filesUrlInFolder[i]
+ if isVirtualArchiveEntryPath(currentURL.absoluteString) {
+ // Virtual archive entries are streamed from archive; skip FS attributes.
+ // Their sort fallback remains name/path based.
+ } else {
+ let resourceValues = try currentURL.resourceValues(forKeys: Set(properties))
+ if let tmp = resourceValues.isAliasFile {
+ isAlias=tmp
+ }
+ if let tmp = resourceValues.fileSize {
+ fileSize=tmp
+ fileSortKey.size=tmp
+ }
+ if let tmp = resourceValues.creationDate {
+ createDate=tmp
+ fileSortKey.createDate=tmp
+ }
+ if let tmp = resourceValues.contentModificationDate {
+ modDate=tmp
+ fileSortKey.modDate=tmp
+ }
+ if let tmp = resourceValues.addedToDirectoryDate {
+ addDate=tmp
+ fileSortKey.addDate=tmp
+ }
+ if let isUbiquitousItem = resourceValues.isUbiquitousItem,
+ isUbiquitousItem,
+ let downloadingStatus = resourceValues.ubiquitousItemDownloadingStatus,
+ downloadingStatus != .current {
+ doNotActualRead=true
+ }
+ let tags = (try? currentURL.resourceValues(forKeys: [.tagNamesKey]))?.tagNames ?? []
+ finderTags = tags
}
- let tags = (try? filesUrlInFolder[i].resourceValues(forKeys: [.tagNamesKey]))?.tagNames ?? []
- finderTags = tags
// finderTags = resourceValues.tagNames ?? []
// 目录
// Directory
@@ -605,6 +779,10 @@ extension ViewController {
}
let tags = (try? subFolders[i-fileCount].resourceValues(forKeys: [.tagNamesKey]))?.tagNames ?? []
finderTags = tags
+ if let counts = directMediaCounts(in: subFolders[i-fileCount]) {
+ childImageCount = counts.images
+ childVideoCount = counts.videos
+ }
// finderTags = resourceValues.tagNames ?? []
}
}catch{
@@ -613,6 +791,8 @@ extension ViewController {
// log("i:",i,"path:",fileSortKey.path.removingPercentEncoding)
let newFileModel=FileModel(path: fileSortKey.path, ver: fileDB.db[SortKeyDir(folderpath)]!.ver, isDir: isDir, isAlias: isAlias, fileSize: fileSize, createDate: createDate, modDate: modDate, addDate: addDate, doNotActualRead: doNotActualRead)
newFileModel.finderTags = finderTags
+ newFileModel.childImageCount = childImageCount
+ newFileModel.childVideoCount = childVideoCount
// log(fileSortKey.path)
if let file = fileDB.db[SortKeyDir(folderpath)]!.files[fileSortKey] {
if file.path == fileSortKey.path {
@@ -621,6 +801,8 @@ extension ViewController {
file.isAlias=isAlias
file.doNotActualRead=doNotActualRead
file.finderTags=finderTags
+ file.childImageCount=childImageCount
+ file.childVideoCount=childVideoCount
// 检查文件或文件夹是否有变化(文件夹fileSize为nil)
// Check if file or folder has changed (folder fileSize is nil)
if fileSize != file.fileSize || modDate != file.modDate {
@@ -848,7 +1030,7 @@ extension ViewController {
if !FileManager.default.fileExists(atPath: path.dropLast().replacingOccurrences(of: "file://", with: "").removingPercentEncoding!) {
if path == "file:///VirtualFinderTagsFolder/" {
collectionView.showFolderInfo(NSLocalizedString("Please select a specific tag", comment: "请选择具体的标签"))
- } else if !path.hasPrefix("file:///VirtualFinderTagsFolder") {
+ } else if !isVirtualFolderPath(path) {
collectionView.showFolderInfo(NSLocalizedString("Directory does not exist", comment: "目录不存在"))
}
}
@@ -938,7 +1120,9 @@ extension ViewController {
/// - checkRange: 增量模式下要检查的indexPaths范围;全量模式下忽略此参数
func selectItemsNewChanged(isFinal: Bool = true, checkRange: [IndexPath]? = nil) {
- let elapsedThreshold = 2.0
+ // Large folders and network volumes can take several seconds to finish
+ // rebuilding. Keep the pending target long enough for the final pass.
+ let elapsedThreshold = 15.0
let curItemCount = collectionView.numberOfItems(inSection: 0)
@@ -954,23 +1138,26 @@ extension ViewController {
if elapsed > elapsedThreshold {
publicVar.folderStepForLocate.removeAll()
} else if let lastURL = URL(string: lastFolder),
- let curURL = URL(string: curFolder),
- lastURL.deletingLastPathComponent().absoluteString == curURL.absoluteString {
- let targetKey = SortKeyFile(lastURL.absoluteString, isDir: true, needGetProperties: true, sortType: publicVar.profile.sortType, isSortFolderFirst: publicVar.profile.isSortFolderFirst, isSortUseFullPath: publicVar.profile.isSortUseFullPath, randomSeed: publicVar.randomSeed)
- fileDB.lock()
- if let files = dirFiles,
- let index = files.index(forKey: targetKey) {
- let offset = files.offset(of: index)
- fileDB.unlock()
- let indexPath = IndexPath(item: offset, section: 0)
- if indexPath.item < curItemCount {
- publicVar.folderStepForLocate.removeAll()
- collectionView.scrollToItems(at: [indexPath], scrollPosition: .nearestHorizontalEdge)
- collectionView.selectItems(at: [indexPath], scrollPosition: [])
- setLoadThumbPriority(ifNeedVisable: true)
+ let curURL = URL(string: curFolder) {
+ let targetURL = parseVirtualArchivePath(lastFolder)?.archiveURL ?? lastURL
+ let isTargetDirectory = targetURL.hasDirectoryPath
+ if targetURL.deletingLastPathComponent().absoluteString == curURL.absoluteString {
+ let targetKey = SortKeyFile(targetURL.absoluteString, isDir: isTargetDirectory, needGetProperties: true, sortType: publicVar.profile.sortType, isSortFolderFirst: publicVar.profile.isSortFolderFirst, isSortUseFullPath: publicVar.profile.isSortUseFullPath, randomSeed: publicVar.randomSeed)
+ fileDB.lock()
+ if let files = dirFiles,
+ let index = files.index(forKey: targetKey) {
+ let offset = files.offset(of: index)
+ fileDB.unlock()
+ let indexPath = IndexPath(item: offset, section: 0)
+ if indexPath.item < curItemCount {
+ publicVar.folderStepForLocate.removeAll()
+ collectionView.scrollToItems(at: [indexPath], scrollPosition: .nearestHorizontalEdge)
+ collectionView.selectItems(at: [indexPath], scrollPosition: [])
+ setLoadThumbPriority(ifNeedVisable: true)
+ }
+ } else {
+ fileDB.unlock()
}
- } else {
- fileDB.unlock()
}
}
}
@@ -981,44 +1168,43 @@ extension ViewController {
let elapsed = Double(DispatchTime.now().uptimeNanoseconds - publicVar.filesForLocateAfterChangeTime.uptimeNanoseconds) / 1_000_000_000
if elapsed > elapsedThreshold {
publicVar.filesForLocateAfterChange.removeAll()
- return
- }
-
- let targetPathSet = Set(publicVar.filesForLocateAfterChange.map {
- $0.hasSuffix("/") ? String($0.dropLast()) : $0
- })
- var matchedIndexPaths = [IndexPath]()
-
- fileDB.lock()
- if let files = dirFiles {
- if let checkRange = checkRange, !isFinal {
- for indexPath in checkRange {
- guard indexPath.item < curItemCount else { continue }
- if let element = files.elementSafe(atOffset: indexPath.item) {
+ } else {
+ let targetPathSet = Set(publicVar.filesForLocateAfterChange.map {
+ $0.hasSuffix("/") ? String($0.dropLast()) : $0
+ })
+ var matchedIndexPaths = [IndexPath]()
+
+ fileDB.lock()
+ if let files = dirFiles {
+ if let checkRange = checkRange, !isFinal {
+ for indexPath in checkRange {
+ guard indexPath.item < curItemCount else { continue }
+ if let element = files.elementSafe(atOffset: indexPath.item) {
+ let normalizedPath = element.0.path.hasSuffix("/") ? String(element.0.path.dropLast()) : element.0.path
+ if targetPathSet.contains(normalizedPath) {
+ matchedIndexPaths.append(indexPath)
+ }
+ }
+ }
+ } else {
+ for (offset, element) in files.enumerated() {
+ guard offset < curItemCount else { continue }
let normalizedPath = element.0.path.hasSuffix("/") ? String(element.0.path.dropLast()) : element.0.path
if targetPathSet.contains(normalizedPath) {
- matchedIndexPaths.append(indexPath)
+ matchedIndexPaths.append(IndexPath(item: offset, section: 0))
}
}
}
- } else {
- for (offset, element) in files.enumerated() {
- guard offset < curItemCount else { continue }
- let normalizedPath = element.0.path.hasSuffix("/") ? String(element.0.path.dropLast()) : element.0.path
- if targetPathSet.contains(normalizedPath) {
- matchedIndexPaths.append(IndexPath(item: offset, section: 0))
- }
- }
}
- }
- fileDB.unlock()
-
- if !matchedIndexPaths.isEmpty {
- let isFirstMatch = collectionView.selectionIndexPaths.isEmpty
- collectionView.selectItems(at: Set(matchedIndexPaths), scrollPosition: [])
- if isFirstMatch {
- collectionView.scrollToItems(at: [matchedIndexPaths[0]], scrollPosition: .nearestHorizontalEdge)
- setLoadThumbPriority(ifNeedVisable: true)
+ fileDB.unlock()
+
+ if !matchedIndexPaths.isEmpty {
+ let isFirstMatch = collectionView.selectionIndexPaths.isEmpty
+ collectionView.selectItems(at: Set(matchedIndexPaths), scrollPosition: [])
+ if isFirstMatch {
+ collectionView.scrollToItems(at: [matchedIndexPaths[0]], scrollPosition: .nearestHorizontalEdge)
+ setLoadThumbPriority(ifNeedVisable: true)
+ }
}
}
@@ -1026,6 +1212,65 @@ extension ViewController {
publicVar.filesForLocateAfterChange.removeAll()
}
}
+
+ if isFinal, let pendingAnchor = publicVar.collectionViewportAnchorAfterRefresh {
+ publicVar.collectionViewportAnchorAfterRefresh = nil
+ if curFolder == pendingAnchor.folderPath {
+ var anchorIndexPath: IndexPath?
+ fileDB.lock()
+ if let files = dirFiles {
+ for (offset, element) in files.enumerated() {
+ if element.1.path == pendingAnchor.filePath {
+ anchorIndexPath = IndexPath(item: offset, section: 0)
+ break
+ }
+ }
+ }
+ fileDB.unlock()
+
+ if let anchorIndexPath {
+ publicVar.collectionScrollRestoreAfterRefresh = nil
+ DispatchQueue.main.async { [weak self] in
+ guard let self,
+ let scrollView = self.collectionView.enclosingScrollView else { return }
+ self.collectionView.layoutSubtreeIfNeeded()
+ guard let frame = self.collectionView.layoutAttributesForItem(at: anchorIndexPath)?.frame else { return }
+ let clipView = scrollView.contentView
+ var proposedBounds = clipView.bounds
+ proposedBounds.origin = NSPoint(
+ x: frame.minX - pendingAnchor.offset.x,
+ y: frame.minY - pendingAnchor.offset.y
+ )
+ let constrainedBounds = clipView.constrainBoundsRect(proposedBounds)
+ clipView.scroll(to: constrainedBounds.origin)
+ scrollView.reflectScrolledClipView(clipView)
+ self.setLoadThumbPriority(ifNeedVisable: true)
+ }
+ return
+ }
+ }
+ }
+
+ if isFinal, let pendingRestore = publicVar.collectionScrollRestoreAfterRefresh {
+ fileDB.lock()
+ let currentFolder = fileDB.curFolder
+ fileDB.unlock()
+ publicVar.collectionScrollRestoreAfterRefresh = nil
+
+ guard currentFolder == pendingRestore.folderPath else { return }
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self,
+ let scrollView = self.collectionView.enclosingScrollView else { return }
+ self.collectionView.layoutSubtreeIfNeeded()
+ let clipView = scrollView.contentView
+ var proposedBounds = clipView.bounds
+ proposedBounds.origin = pendingRestore.origin
+ let constrainedBounds = clipView.constrainBoundsRect(proposedBounds)
+ clipView.scroll(to: constrainedBounds.origin)
+ scrollView.reflectScrolledClipView(clipView)
+ self.setLoadThumbPriority(ifNeedVisable: true)
+ }
+ }
}
func switchDirByDirection(direction rawdirection: RightMouseGestureDirection, dest: String = "", doCollapse: Bool = true, expandLast: Bool = true, skip: Bool = false, stackDeep: Int, dryRun: Bool = false, needStopAutoScroll: Bool = true){
@@ -1045,7 +1290,7 @@ extension ViewController {
fileDB.lock()
let curFolder = fileDB.curFolder
fileDB.unlock()
- if curFolder.hasPrefix("file:///VirtualFinderTagsFolder") || !publicVar.finderTagFilters.isEmpty || !publicVar.ratingFilters.isEmpty {
+ if isVirtualFolderPath(curFolder) || !publicVar.finderTagFilters.isEmpty || !publicVar.ratingFilters.isEmpty {
if rawdirection == .left || rawdirection == .up_left || rawdirection == .down_left
|| rawdirection == .right || rawdirection == .up_right || rawdirection == .down_right {
return
@@ -1131,6 +1376,16 @@ extension ViewController {
// 跳转父级目录
// Jump to parent directory
if direction == .up {
+ // In virtual archive view, Command+Up should leave archive and go to
+ // the real parent directory of the archive file (e.g. SMB folder).
+ if isVirtualArchivePath(curFolder),
+ let parsed = parseVirtualArchivePath(curFolder) {
+ let realParent = parsed.archiveURL.deletingLastPathComponent().absoluteString
+ if !realParent.isEmpty {
+ switchDirByDirection(direction: .zero, dest: realParent, skip: true, stackDeep: stackDeep+1)
+ return
+ }
+ }
fileDB.lock()
let newFolderPath=URL(string: fileDB.curFolder)!.deletingLastPathComponent().absoluteString
fileDB.unlock()
diff --git a/FlowVision/Sources/ViewControllerExtension/KeyShortcut.swift b/FlowVision/Sources/ViewControllerExtension/KeyShortcut.swift
index fd8b13fa..35e61edc 100644
--- a/FlowVision/Sources/ViewControllerExtension/KeyShortcut.swift
+++ b/FlowVision/Sources/ViewControllerExtension/KeyShortcut.swift
@@ -7,6 +7,130 @@ import Foundation
import Cocoa
extension ViewController {
+
+ /// Chinese input sources may emit localized punctuation for the same physical
+ /// punctuation key. Keep shortcut matching independent of that input-mode
+ /// difference while preserving normal text input everywhere else.
+ private func normalizedShortcutCharacters(_ value: String) -> String {
+ let punctuationMap: [Character: Character] = [
+ "【": "[", "[": "[",
+ "】": "]", "]": "]",
+ ",": ",", "。": ".",
+ "=": "=", "-": "-"
+ ]
+ return String(value.map { punctuationMap[$0] ?? $0 })
+ }
+
+ private func isConfiguredFolderCopyShortcutTriggered(_ configuredShortcut: String, characters: String, specialKey: NSEvent.SpecialKey, noModifierKey: Bool) -> Bool {
+ guard noModifierKey else { return false }
+
+ let shortcut = normalizedShortcutCharacters(
+ configuredShortcut.trimmingCharacters(in: .whitespacesAndNewlines)
+ ).uppercased()
+ if shortcut.isEmpty { return false }
+
+ switch shortcut {
+ case "F1": return specialKey == .f1
+ case "F2": return specialKey == .f2
+ case "F3": return specialKey == .f3
+ case "F4": return specialKey == .f4
+ case "F5": return specialKey == .f5
+ case "F6": return specialKey == .f6
+ case "F7": return specialKey == .f7
+ case "F8": return specialKey == .f8
+ case "F9": return specialKey == .f9
+ case "F10": return specialKey == .f10
+ case "F11": return specialKey == .f11
+ case "F12": return specialKey == .f12
+ default:
+ return characters.uppercased() == shortcut
+ }
+ }
+
+ private func isPhotoFolder1CopyShortcutTriggered(characters: String, specialKey: NSEvent.SpecialKey, noModifierKey: Bool) -> Bool {
+ isConfiguredFolderCopyShortcutTriggered(globalVar.photoFolder1CopyShortcut, characters: characters, specialKey: specialKey, noModifierKey: noModifierKey)
+ }
+
+ private func isPhotoFolder2CopyShortcutTriggered(characters: String, specialKey: NSEvent.SpecialKey, noModifierKey: Bool) -> Bool {
+ isConfiguredFolderCopyShortcutTriggered(globalVar.photoFolder2CopyShortcut, characters: characters, specialKey: specialKey, noModifierKey: noModifierKey)
+ }
+
+ @discardableResult
+ private func enterSelectedFolderFromKeyboard() -> Bool {
+ guard let selectedURL = publicVar.selectedUrls().first else { return false }
+
+ var targetFolderURL: URL? = nil
+ if selectedURL.hasDirectoryPath {
+ targetFolderURL = selectedURL
+ } else if let values = try? selectedURL.resourceValues(forKeys: [.isAliasFileKey, .isSymbolicLinkKey]),
+ values.isAliasFile == true,
+ let resolved = try? URL(resolvingAliasFileAt: selectedURL),
+ resolved.hasDirectoryPath {
+ targetFolderURL = resolved
+ }
+
+ guard let folderURL = targetFolderURL else { return false }
+ switchDirByDirection(direction: .zero, dest: folderURL.absoluteString, stackDeep: 0)
+ return true
+ }
+
+ @discardableResult
+ private func openSelectedItemFromKeyboard() -> Bool {
+ guard !publicVar.isInLargeView,
+ publicVar.isCollectionViewFirstResponder,
+ let indexPath = collectionView.selectionIndexPaths.min() else {
+ return false
+ }
+
+ openLargeImage(indexPath)
+ return true
+ }
+
+ @discardableResult
+ private func triggerRightClickContextMenuFromKeyboard() -> Bool {
+ guard let window = view.window else { return false }
+
+ let targetView: NSView
+ if let selectedIndexPath = collectionView.selectionIndexPaths.first,
+ let item = collectionView.item(at: selectedIndexPath) as? CustomCollectionViewItem {
+ targetView = item.view
+ } else {
+ targetView = collectionView
+ }
+
+ let localCenter = NSPoint(x: targetView.bounds.midX, y: targetView.bounds.midY)
+ let windowPoint = targetView.convert(localCenter, to: nil)
+ let timestamp = ProcessInfo.processInfo.systemUptime
+
+ guard let downEvent = NSEvent.mouseEvent(
+ with: .rightMouseDown,
+ location: windowPoint,
+ modifierFlags: [],
+ timestamp: timestamp,
+ windowNumber: window.windowNumber,
+ context: nil,
+ eventNumber: 0,
+ clickCount: 1,
+ pressure: 1.0
+ ),
+ let upEvent = NSEvent.mouseEvent(
+ with: .rightMouseUp,
+ location: windowPoint,
+ modifierFlags: [],
+ timestamp: timestamp + 0.01,
+ windowNumber: window.windowNumber,
+ context: nil,
+ eventNumber: 1,
+ clickCount: 1,
+ pressure: 0.0
+ ) else {
+ return false
+ }
+
+ targetView.rightMouseDown(with: downEvent)
+ targetView.rightMouseUp(with: upEvent)
+ return true
+ }
func KeyShortcutManager (event: NSEvent) -> NSEvent?
{
@@ -41,9 +165,17 @@ extension ViewController {
let isOnlyCtrlPressed = !isCommandPressed && !isAltPressed && isCtrlPressed && !isShiftPressed
let isOnlyShiftPressed = !isCommandPressed && !isAltPressed && !isCtrlPressed && isShiftPressed
- let characters = (event.charactersIgnoringModifiers ?? "").lowercased()
+ let characters = normalizedShortcutCharacters(event.charactersIgnoringModifiers ?? "").lowercased()
let specialKey = event.specialKey ?? .f30
+ if publicVar.isInLargeView && largeImageView.isInVideoCropSelectionMode {
+ if event.keyCode == 53 {
+ largeImageView.cancelVideoCropSelection()
+ return nil
+ }
+ return event
+ }
+
// 把按键信息打印出来,用于调试不同键盘的键值差异
// var modifierStrings: [String] = []
// if isCommandPressed { modifierStrings.append("Command") }
@@ -102,6 +234,19 @@ extension ViewController {
}
}
}
+
+ // 主界面撤销 / 重做
+ // Undo / redo in normal browsing state
+ if publicVar.isKeyEventEnabled && isCommandPressed && !isAltPressed && !isCtrlPressed {
+ if characters == "z" && !isShiftPressed {
+ NSApp.sendAction(Selector(("undo:")), to: nil, from: nil)
+ return nil
+ }
+ if characters == "z" && isShiftPressed {
+ NSApp.sendAction(Selector(("redo:")), to: nil, from: nil)
+ return nil
+ }
+ }
// 防止过快触发事件
// Prevent events from triggering too quickly
@@ -143,6 +288,27 @@ extension ViewController {
}
if publicVar.isKeyEventEnabled {
+ // 自定义快捷键:复制到图片文件夹1
+ // Custom shortcut: copy to Photo Folder 1
+ if publicVar.isCollectionViewFirstResponder &&
+ isPhotoFolder1CopyShortcutTriggered(characters: characters, specialKey: specialKey, noModifierKey: noModifierKey) {
+ handleCopyToPhotoFolder1()
+ return nil
+ }
+
+ // 自定义快捷键:复制视频到文件夹2
+ // Custom shortcut: copy video to Folder 2
+ if isPhotoFolder2CopyShortcutTriggered(characters: characters, specialKey: specialKey, noModifierKey: noModifierKey) {
+ if publicVar.isInLargeView,
+ largeImageView.file.type == .video {
+ handleCopyCurrentVideoToPhotoFolder2()
+ return nil
+ }
+ if publicVar.isCollectionViewFirstResponder {
+ handleCopySelectedVideosToPhotoFolder2()
+ return nil
+ }
+ }
// 检查按键是否是 "A" 键
// Check if key is "A"
@@ -354,6 +520,16 @@ extension ViewController {
}
return nil
}
+
+ // 检查按键是否是 Command+"E" 键(视频截图到当前文件夹)
+ // Check if key is Command+"E" (capture current video frame to current folder)
+ if characters == "e" && isOnlyCommandPressed {
+ if publicVar.isInLargeView,
+ largeImageView.file.type == .video {
+ handleCaptureCurrentVideoFrameToCurrentFolder()
+ return nil
+ }
+ }
// 检查按键是否是 Command+⬅️➡️ 键
// Check if key is Command+⬅️➡️
@@ -368,15 +544,47 @@ extension ViewController {
largeImageView.seekVideoByFrame(direction: isRTL_Cmd ? -1 : 1)
}
return nil
+ } else if !publicVar.isInLargeView,
+ specialKey == .rightArrow {
+ // Keyboard equivalent of mouse right click context menu.
+ if triggerRightClickContextMenuFromKeyboard() {
+ return nil
+ }
+ }
+ }
+
+ // 检查按键是否是 Shift+⬅️➡️ 键(视频切换上/下文件)
+ // Check if key is Shift+⬅️➡️ (video switch previous/next file)
+ if (specialKey == .leftArrow || specialKey == .rightArrow) && isOnlyShiftPressed {
+ if globalVar.videoShiftArrowSwitchFile,
+ publicVar.isInLargeView,
+ largeImageView.file.type == .video {
+ if specialKey == .leftArrow {
+ previousLargeImage()
+ } else {
+ nextLargeImage()
+ }
+ return nil
}
}
- // 检查按键是否是 Command+⬆️ 键
- // Check if key is Command+⬆️
- if (specialKey == .upArrow && isOnlyCommandPressed) || (specialKey == .home && noModifierKey) {
- if publicVar.isInLargeView{
+ // 检查按键是否是 Command+⬆️ 键(查看时退出查看,缩略图时返回上一级目录)
+ // Check if key is Command+⬆️ (exit large view, or go to parent folder)
+ if specialKey == .upArrow && isOnlyCommandPressed {
+ if publicVar.isInLargeView {
+ closeLargeImage(0)
+ } else {
+ switchDirByDirection(direction: .up, stackDeep: 0)
+ }
+ return nil
+ }
+
+ // 检查按键是否是 Home 键(滚动到顶部)
+ // Check if key is Home key (scroll to top)
+ if specialKey == .home && noModifierKey {
+ if publicVar.isInLargeView {
locateLargeImage(direction: -2)
- }else{
+ } else {
if let scrollView = collectionView.enclosingScrollView {
scrollView.contentView.scroll(to: NSPoint(x: 0, y: 0))
scrollView.reflectScrolledClipView(scrollView.contentView)
@@ -388,12 +596,19 @@ extension ViewController {
return nil
}
- // 检查按键是否是 Command+⬇️ 键
- // Check if key is Command+⬇️
- if (specialKey == .downArrow && isOnlyCommandPressed) || (specialKey == .end && noModifierKey) {
- if publicVar.isInLargeView{
- locateLargeImage(direction: 2)
- }else{
+ // 检查按键是否是 Command+⬇️ 键(打开选中文件/进入选中文件夹)
+ // Check if key is Command+⬇️ (open selected item, or enter selected folder)
+ if specialKey == .downArrow && isOnlyCommandPressed {
+ if publicVar.isInLargeView {
+ return nil
+ } else {
+ if enterSelectedFolderFromKeyboard() {
+ return nil
+ }
+ if openSelectedItemFromKeyboard() {
+ return nil
+ }
+ // Fallback: keep original behavior when selection is not a folder.
if let scrollView = collectionView.enclosingScrollView {
let newOrigin = NSPoint(x: 0, y: collectionView.bounds.height - scrollView.contentSize.height)
scrollView.contentView.scroll(to: newOrigin)
@@ -406,6 +621,22 @@ extension ViewController {
return nil
}
+ // 检查按键是否是 End 键
+ // Check if key is End key
+ if specialKey == .end && noModifierKey {
+ if publicVar.isInLargeView {
+ locateLargeImage(direction: 2)
+ } else if let scrollView = collectionView.enclosingScrollView {
+ let newOrigin = NSPoint(x: 0, y: collectionView.bounds.height - scrollView.contentSize.height)
+ scrollView.contentView.scroll(to: newOrigin)
+ scrollView.reflectScrolledClipView(scrollView.contentView)
+ DispatchQueue.main.async { [weak self] in
+ self?.setLoadThumbPriority(ifNeedVisable: true)
+ }
+ }
+ return nil
+ }
+
// 检查按键是否是 Opt+⬆️ 键
// Check if key is Opt+⬆️
if (specialKey == .upArrow && isOnlyAltPressed) || (specialKey == .pageUp && noModifierKey) {
@@ -926,17 +1157,6 @@ extension ViewController {
}
}
- // 检查按键是否是 "N" 键
- // Check if key is "N"
- if characters == "n" && noModifierKey {
- // 如果焦点在CollectionView
- // If focus is in CollectionView
- if publicVar.isCollectionViewFirstResponder{
- handleCopyToDownload()
- return nil
- }
- }
-
// 检查按键是否是 "M" 键
// Check if key is "M"
if characters == "m" && noModifierKey {
diff --git a/FlowVision/Sources/ViewControllerExtension/LargeImage.swift b/FlowVision/Sources/ViewControllerExtension/LargeImage.swift
index a19e75c2..060235a9 100644
--- a/FlowVision/Sources/ViewControllerExtension/LargeImage.swift
+++ b/FlowVision/Sources/ViewControllerExtension/LargeImage.swift
@@ -221,6 +221,8 @@ extension ViewController {
let resolvedAbsPath = resolved.absoluteString
if resolved.hasDirectoryPath {
switchDirByDirection(direction: .zero, dest: resolvedAbsPath, stackDeep: 0)
+ } else if isSupportedArchiveURL(resolved) {
+ _ = openArchiveAsVirtualFolder(resolved)
} else if globalVar.HandledImageAndRawExtensions.contains(resolved.pathExtension.lowercased()) ||
(globalVar.useInternalPlayer && globalVar.HandledNativeSupportedVideoExtensions.contains(resolved.pathExtension.lowercased())) {
if let appDelegate = NSApplication.shared.delegate as? AppDelegate {
@@ -229,6 +231,8 @@ extension ViewController {
appDelegate.openImageInTargetWindow(resolvedAbsPath, windowController: windowController)
}
}
+ } else if globalVar.HandledVideoExtensions.contains(resolved.pathExtension.lowercased()) {
+ openVideoWithPreferredExternalPlayer(resolved)
} else {
NSWorkspace.shared.open(resolved)
}
@@ -237,10 +241,16 @@ extension ViewController {
if(url.hasDirectoryPath){
switchDirByDirection(direction: .zero, dest: item.file.path, stackDeep: 0)
+ } else if isSupportedArchiveURL(url) {
+ _ = openArchiveAsVirtualFolder(url)
}
else if !globalVar.HandledImageAndRawExtensions.contains(url.pathExtension.lowercased()) &&
!(globalVar.useInternalPlayer && globalVar.HandledNativeSupportedVideoExtensions.contains(item.file.ext)) {
- NSWorkspace.shared.open(url)
+ if globalVar.HandledVideoExtensions.contains(url.pathExtension.lowercased()) {
+ openVideoWithPreferredExternalPlayer(url)
+ } else {
+ NSWorkspace.shared.open(url)
+ }
}else{
if largeImageView.isHidden {
@@ -497,143 +507,72 @@ extension ViewController {
}
fileDB.lock()
- let curFolder=fileDB.curFolder
- let totalCount = fileDB.db[SortKeyDir(curFolder)]!.files.count
- guard let path = fileDB.db[SortKeyDir(curFolder)]!.files.elementSafe(atOffset: currLargeImagePos)?.1.path,
- let url = URL(string: path)
- else{
+ let curFolder = fileDB.curFolder
+ guard let directory = fileDB.db[SortKeyDir(curFolder)] else {
fileDB.unlock()
return
}
- fileDB.unlock()
-
- var threadNum: Int
- if VolumeManager.shared.isExternalVolume(url) {
- threadNum=globalVar.thumbThreadNum_External
- }else{
- threadNum=globalVar.thumbThreadNum
- }
-// let preloadNumNext=Int(ceil(Double(threadNum)*0.75))
-// let preloadNumPrevious=Int(ceil(Double(threadNum)*0.25))
- var preloadNumNext:Int
- var preloadNumPrevious:Int
-
- if threadNum == 1 {
- preloadNumNext = 0
- preloadNumPrevious = 0
- }else if threadNum <= 4 {
- preloadNumNext = 1
- preloadNumPrevious = 1
- }else{
- preloadNumNext = 3
- preloadNumPrevious = 2
- }
-
- var fileQueue = [(FileModel, Double)]()
+ let totalCount = directory.files.count
+ var mediaQueue = [(file: FileModel, distance: Int)]()
- // 后面的图像
- // Images after current
- do{
- fileDB.lock()
- var nextLargeImagePos=currLargeImagePos
- var loadCount=0
- while nextLargeImagePos < totalCount-1 {
- nextLargeImagePos += 1
- if let file=fileDB.db[SortKeyDir(curFolder)]!.files.elementSafe(atOffset: nextLargeImagePos)?.1 {
- if file.type == .image || (file.type == .video && globalVar.useInternalPlayer) {
- loadCount += 1
- // 预载入数量
- // Preload count
- if loadCount > preloadNumNext { break }
- }
- if file.type == .image {
- fileQueue.append((file, Double(loadCount)-0.5))
- }
- }
- }
- fileDB.unlock()
+ if let current = directory.files.elementSafe(atOffset: currLargeImagePos)?.1,
+ current.type == .image || (current.type == .video && globalVar.useInternalPlayer) {
+ mediaQueue.append((current, 0))
}
-
- // 前面的图像
- // Images before current
- do{
- fileDB.lock()
- var nextLargeImagePos=currLargeImagePos
- var loadCount=0
- while nextLargeImagePos >= 0 {
- nextLargeImagePos -= 1
- if let file=fileDB.db[SortKeyDir(curFolder)]!.files.elementSafe(atOffset: nextLargeImagePos)?.1 {
- if file.type == .image || (file.type == .video && globalVar.useInternalPlayer) {
- loadCount += 1
- // 预载入数量
- // Preload count
- if loadCount > preloadNumPrevious { break }
- }
- if file.type == .image {
- fileQueue.append((file, Double(loadCount)))
- }
+
+ for direction in [-1, 1] {
+ var position = currLargeImagePos
+ var mediaDistance = 0
+ while position >= 0 && position < totalCount && mediaDistance < 5 {
+ position += direction
+ guard position >= 0 && position < totalCount else { break }
+ guard let file = directory.files.elementSafe(atOffset: position)?.1 else { continue }
+ if file.type == .image || (file.type == .video && globalVar.useInternalPlayer) {
+ mediaDistance += 1
+ mediaQueue.append((file, direction * mediaDistance))
}
}
- fileDB.unlock()
}
-
- // 当前图像
- // Current image
- do{
- fileDB.lock()
- if let file=fileDB.db[SortKeyDir(curFolder)]!.files.elementSafe(atOffset: currLargeImagePos)?.1,
- file.type == .image{
- fileQueue.append((file, 0))
+ fileDB.unlock()
+
+ let retainedURLs = mediaQueue.compactMap { URL(string: $0.file.path) }
+ let generation = mediaPreheatManager.beginWindow(retaining: retainedURLs)
+
+ for entry in mediaQueue.sorted(by: { abs($0.distance) < abs($1.distance) }) {
+ guard let url = URL(string: entry.file.path) else { continue }
+ if entry.file.type == .image {
+ preloadLargeImageForFile(file: entry.file, distance: entry.distance, generation: generation)
+ } else if entry.distance != 0 {
+ mediaPreheatManager.scheduleVideo(
+ url: url,
+ generation: generation,
+ distance: entry.distance,
+ seconds: 5
+ )
}
- fileDB.unlock()
- }
-
- // 排序后预载入
- // Preload after sorting
- fileDB.lock()
- fileQueue.sort { $0.1 > $1.1 }
- for (file,priority) in fileQueue {
- preloadLargeImageForFile(file: file, priority: priority)
}
- fileDB.unlock()
}
- func preloadLargeImageForFile(file: FileModel, priority: Double){
+ func preloadLargeImageForFile(file: FileModel, distance: Int, generation: Int){
if file.type != .image {return}
let url=URL(string:file.path)!
let scale = NSScreen.main?.backingScaleFactor ?? 1
let maxBounds=largeImageView.bounds
- // print(maxBounds)
-
- var largeSize: NSSize
- var originalSize: NSSize? = file.originalSize
-
- // 当文件被修改,列表重新读取但大小还没来得及获取时可能为空,此时需要获取一下
- // When file is modified, list is re-read but size may not be obtained yet, need to get it
- // 或者由于外置卷,使用的默认大小 || VolumeManager.shared.isExternalVolume(url)
- // Or due to external volume, use default size || VolumeManager.shared.isExternalVolume(url)
- if originalSize == nil {
- let imageInfo = getImageInfo(url: url, needMetadata: true)
- originalSize = imageInfo?.size
- file.imageInfo = imageInfo
- file.originalSize = originalSize
+ let fitWindow = publicVar.isLargeImageFitWindow
+ let rawUseEmbeddedThumb = publicVar.isRawUseEmbeddedThumb
+ let enableHDR = publicVar.isEnableHDR
+
+ mediaPreheatManager.scheduleImage(generation: generation, distance: distance) {
+ let loadedImageInfo = file.imageInfo ?? getImageInfo(url: url, needMetadata: true)
+ let originalSize = file.originalSize ?? loadedImageInfo?.size ?? DEFAULT_SIZE
+ var largeSize: NSSize
- if originalSize == nil {
- originalSize = DEFAULT_SIZE
- file.isGetImageSizeFail = true
- }else{
- file.isGetImageSizeFail = false
- }
- }
-
- if let originalSize=originalSize{
-
// 判断HDR
// Determine HDR
- var isHDR = (file.imageInfo?.isHDR ?? false) && publicVar.isEnableHDR
- if globalVar.HandledRawExtensions.contains(url.pathExtension.lowercased()) && publicVar.isRawUseEmbeddedThumb {
+ var isHDR = (loadedImageInfo?.isHDR ?? false) && enableHDR
+ if globalVar.HandledRawExtensions.contains(url.pathExtension.lowercased()) && rawUseEmbeddedThumb {
isHDR = false
}
@@ -647,7 +586,7 @@ extension ViewController {
// 当原图实际大小小于视图大小时,按实际大小显示
// When original image actual size is smaller than view size, display at actual size
- if !publicVar.isLargeImageFitWindow && originalSize.width Void)?
+ private var operationOverlayView: NSView?
+ private var operationMessageLabel: NSTextField?
+ private var operationProgressBar: NSProgressIndicator?
+ private var operationHideWorkItem: DispatchWorkItem?
+
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
commonInit()
@@ -127,6 +132,144 @@ class CoreAreaView: NSView {
scanProgressLabel = label
}
+ // MARK: - Operation Overlay (Toast / Progress)
+
+ func showOperationToast(_ message: String, autoHide: Double = 2.0) {
+ setupOperationOverlayIfNeeded()
+ guard let overlay = operationOverlayView,
+ let label = operationMessageLabel,
+ let progress = operationProgressBar else { return }
+
+ operationHideWorkItem?.cancel()
+ label.stringValue = message
+ progress.isHidden = true
+
+ showOperationOverlayAnimatedIfNeeded(overlay)
+ if autoHide > 0 {
+ let work = DispatchWorkItem { [weak self] in
+ self?.hideOperationOverlay()
+ }
+ operationHideWorkItem = work
+ DispatchQueue.main.asyncAfter(deadline: .now() + autoHide, execute: work)
+ }
+ }
+
+ func showOperationProgress(_ message: String, progress: Double) {
+ setupOperationOverlayIfNeeded()
+ guard let overlay = operationOverlayView,
+ let label = operationMessageLabel,
+ let progressBar = operationProgressBar else { return }
+
+ operationHideWorkItem?.cancel()
+ label.stringValue = message
+ progressBar.isHidden = false
+ if progressBar.isIndeterminate {
+ progressBar.stopAnimation(nil)
+ progressBar.isIndeterminate = false
+ }
+ progressBar.doubleValue = min(max(progress, 0), 1) * 100.0
+ showOperationOverlayAnimatedIfNeeded(overlay)
+ }
+
+ func showOperationIndeterminate(_ message: String) {
+ setupOperationOverlayIfNeeded()
+ guard let overlay = operationOverlayView,
+ let label = operationMessageLabel,
+ let progressBar = operationProgressBar else { return }
+
+ operationHideWorkItem?.cancel()
+ label.stringValue = message
+ progressBar.isHidden = false
+ progressBar.isIndeterminate = true
+ progressBar.startAnimation(nil)
+ showOperationOverlayAnimatedIfNeeded(overlay)
+ }
+
+ func hideOperationOverlay(delayed: Double = 0) {
+ operationHideWorkItem?.cancel()
+ guard let overlay = operationOverlayView, !overlay.isHidden else { return }
+ let hide = {
+ NSAnimationContext.runAnimationGroup({ context in
+ context.duration = 0.25
+ overlay.animator().alphaValue = 0
+ }) {
+ self.operationProgressBar?.stopAnimation(nil)
+ self.operationProgressBar?.isIndeterminate = false
+ overlay.isHidden = true
+ }
+ }
+ if delayed > 0 {
+ DispatchQueue.main.asyncAfter(deadline: .now() + delayed) { hide() }
+ } else {
+ hide()
+ }
+ }
+
+ private func setupOperationOverlayIfNeeded() {
+ if operationOverlayView != nil { return }
+
+ let container = NSView()
+ container.wantsLayer = true
+ container.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.95).cgColor
+ container.layer?.cornerRadius = 8
+ container.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.6).cgColor
+ container.layer?.borderWidth = 1
+ container.translatesAutoresizingMaskIntoConstraints = false
+
+ let label = NSTextField(labelWithString: "")
+ label.textColor = .labelColor
+ label.font = NSFont.systemFont(ofSize: 12, weight: .medium)
+ label.lineBreakMode = .byTruncatingMiddle
+ label.maximumNumberOfLines = 1
+ label.translatesAutoresizingMaskIntoConstraints = false
+
+ let progressBar = NSProgressIndicator()
+ progressBar.isIndeterminate = false
+ progressBar.minValue = 0
+ progressBar.maxValue = 100
+ progressBar.controlSize = .small
+ progressBar.style = .bar
+ progressBar.translatesAutoresizingMaskIntoConstraints = false
+ progressBar.isHidden = true
+
+ container.addSubview(label)
+ container.addSubview(progressBar)
+ addSubview(container)
+
+ NSLayoutConstraint.activate([
+ container.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16),
+ container.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16),
+ container.widthAnchor.constraint(lessThanOrEqualToConstant: 420),
+ container.widthAnchor.constraint(greaterThanOrEqualToConstant: 260),
+
+ label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12),
+ label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12),
+ label.topAnchor.constraint(equalTo: container.topAnchor, constant: 10),
+
+ progressBar.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12),
+ progressBar.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12),
+ progressBar.topAnchor.constraint(equalTo: label.bottomAnchor, constant: 8),
+ progressBar.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -10),
+ progressBar.heightAnchor.constraint(equalToConstant: 10),
+ ])
+
+ container.isHidden = true
+ operationOverlayView = container
+ operationMessageLabel = label
+ operationProgressBar = progressBar
+ }
+
+ private func showOperationOverlayAnimatedIfNeeded(_ overlay: NSView) {
+ if overlay.isHidden {
+ overlay.isHidden = false
+ overlay.alphaValue = 0
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = 0.2
+ overlay.animator().alphaValue = 1
+ }
+ }
+ }
+
override func awakeFromNib() {
super.awakeFromNib()
registerForDraggedTypes([.fileURL] + NSFilePromiseReceiver.readableDraggedTypes.map { NSPasteboard.PasteboardType($0) })
diff --git a/FlowVision/Sources/Views/CustomCollectionView.swift b/FlowVision/Sources/Views/CustomCollectionView.swift
index a6cafd02..feec76eb 100644
--- a/FlowVision/Sources/Views/CustomCollectionView.swift
+++ b/FlowVision/Sources/Views/CustomCollectionView.swift
@@ -118,7 +118,7 @@ class CustomCollectionView: NSCollectionView {
}
let curFolder = getViewController(self)!.fileDB.curFolder
- let isVirtualFinderTagsFolder = curFolder.hasPrefix("file:///VirtualFinderTagsFolder")
+ let isReadOnlyVirtualFolder = isReadOnlyVirtualFolderPath(curFolder)
// 弹出菜单
// Show context menu
@@ -126,16 +126,22 @@ class CustomCollectionView: NSCollectionView {
menu.autoenablesItems = false
let actionItemOpenInFinder = menu.addItem(withTitle: NSLocalizedString("Open in Finder", comment: "在Finder中打开"), action: #selector(actOpenInFinder), keyEquivalent: "")
- actionItemOpenInFinder.isEnabled = !isVirtualFinderTagsFolder
+ actionItemOpenInFinder.isEnabled = !isReadOnlyVirtualFolder
+
+ if isFavoritePath(curFolder) {
+ menu.addItem(withTitle: NSLocalizedString("Remove from Favorites", comment: "取消收藏"), action: #selector(actRemoveFromFavorites), keyEquivalent: "")
+ } else {
+ menu.addItem(withTitle: NSLocalizedString("Add Current Folder to Favorites", comment: "收藏当前目录"), action: #selector(actAddCurrentFolderToFavorites), keyEquivalent: "")
+ }
menu.addItem(NSMenuItem.separator())
let actionItemPaste = menu.addItem(withTitle: NSLocalizedString("Paste", comment: "粘贴"), action: #selector(actPaste), keyEquivalent: "v")
- actionItemPaste.isEnabled = canPasteOrMove && !isVirtualFinderTagsFolder
+ actionItemPaste.isEnabled = canPasteOrMove && !isReadOnlyVirtualFolder
let actionItemMove = menu.addItem(withTitle: NSLocalizedString("Move Here", comment: "移动到此"), action: #selector(actMove), keyEquivalent: "v")
actionItemMove.keyEquivalentModifierMask = [.command,.option]
- actionItemMove.isEnabled = canPasteOrMove && !isVirtualFinderTagsFolder
+ actionItemMove.isEnabled = canPasteOrMove && !isReadOnlyVirtualFolder
menu.addItem(NSMenuItem.separator())
@@ -146,7 +152,7 @@ class CustomCollectionView: NSCollectionView {
// let actionItemCopyPath = menu.addItem(withTitle: NSLocalizedString("Copy Path", comment: "复制路径"), action: #selector(actCopyPath), keyEquivalent: "")
let actionItemOpenInTerminal = menu.addItem(withTitle: NSLocalizedString("Open in Terminal", comment: "在终端中打开"), action: #selector(actOpenInTerminal), keyEquivalent: "")
- actionItemOpenInTerminal.isEnabled = !isVirtualFinderTagsFolder
+ actionItemOpenInTerminal.isEnabled = !isReadOnlyVirtualFolder
menu.addItem(NSMenuItem.separator())
@@ -155,7 +161,7 @@ class CustomCollectionView: NSCollectionView {
let newMenu = NSMenu()
let newMenuItem = NSMenuItem(title: NSLocalizedString("New", comment: "新建"), action: nil, keyEquivalent: "")
newMenuItem.submenu = newMenu
- newMenuItem.isEnabled = !isVirtualFinderTagsFolder
+ newMenuItem.isEnabled = !isReadOnlyVirtualFolder
// 添加新建文件夹选项
// Add new folder option
@@ -195,6 +201,20 @@ class CustomCollectionView: NSCollectionView {
NSWorkspace.shared.open(URL(string: folderURL)!)
}
}
+
+ @objc func actAddCurrentFolderToFavorites() {
+ guard let folderURL = getViewController(self)?.fileDB.curFolder else { return }
+ if addFavoritePath(folderURL) {
+ getViewController(self)?.refreshTreeView()
+ }
+ }
+
+ @objc func actRemoveFromFavorites() {
+ guard let folderURL = getViewController(self)?.fileDB.curFolder else { return }
+ if removeFavoritePath(folderURL) {
+ getViewController(self)?.refreshTreeView()
+ }
+ }
@objc func actNewFolder() {
getViewController(self)?.handleNewFolder()
diff --git a/FlowVision/Sources/Views/CustomCollectionViewItem.swift b/FlowVision/Sources/Views/CustomCollectionViewItem.swift
index 9662e54e..96642c32 100644
--- a/FlowVision/Sources/Views/CustomCollectionViewItem.swift
+++ b/FlowVision/Sources/Views/CustomCollectionViewItem.swift
@@ -28,6 +28,7 @@ class CustomCollectionViewItem: NSCollectionViewItem {
var finderTagDotsView: NSView?
var ratingStarsView: NSView?
var aliasBadgeView: NSImageView?
+ var folderMediaCountBadgeView: NSTextField?
private var mouseDownLocation: NSPoint? = nil
private var lastClickTime: TimeInterval = 0
@@ -37,6 +38,27 @@ class CustomCollectionViewItem: NSCollectionViewItem {
private let positionThreshold: CGFloat = 4.0
private var middleMouseLastLocation: NSPoint = NSPoint.zero
+
+ override var draggingImageComponents: [NSDraggingImageComponent] {
+ guard let collectionView,
+ collectionView.selectionIndexPaths.count > 8 else {
+ return super.draggingImageComponents
+ }
+
+ // Avoid asking every selected video thumbnail/player view to render a
+ // separate drag image. The session replaces these lightweight placeholders
+ // with one compact count badge before the drag becomes visible.
+ let component = NSDraggingImageComponent(key: .icon)
+ component.contents = NSImage(named: NSImage.multipleDocumentsName)
+ let side = min(max(32, min(view.bounds.width, view.bounds.height)), 48)
+ component.frame = NSRect(
+ x: max(0, (view.bounds.width - side) / 2),
+ y: max(0, (view.bounds.height - side) / 2),
+ width: side,
+ height: side
+ )
+ return [component]
+ }
override func viewDidLoad() {
super.viewDidLoad()
@@ -300,6 +322,65 @@ class CustomCollectionViewItem: NSCollectionViewItem {
ratingStarsView = nil
aliasBadgeView?.removeFromSuperview()
aliasBadgeView = nil
+ folderMediaCountBadgeView?.removeFromSuperview()
+ folderMediaCountBadgeView = nil
+ }
+
+ func refreshFolderMediaCountBadge() {
+ folderMediaCountBadgeView?.removeFromSuperview()
+ folderMediaCountBadgeView = nil
+
+ guard globalVar.showFolderMediaCountBadge,
+ file.isDir,
+ let imageCount = file.childImageCount,
+ let videoCount = file.childVideoCount,
+ imageCount + videoCount > 0
+ else {
+ return
+ }
+
+ var parts = [String]()
+ if imageCount > 0 {
+ parts.append("\(imageCount)\(NSLocalizedString("Image", comment: "图像"))")
+ }
+ if videoCount > 0 {
+ parts.append("\(videoCount)\(NSLocalizedString("Video", comment: "视频"))")
+ }
+
+ let scale = tagScaleFactor()
+ let fontSize: CGFloat = round(11 * scale)
+ let horizontalPadding: CGFloat = round(8 * scale)
+ let verticalPadding: CGFloat = round(4 * scale)
+ let inset: CGFloat = round(5 * scale)
+ let font = NSFont.systemFont(ofSize: fontSize, weight: .semibold)
+ let text = parts.joined(separator: " ")
+ let textWidth = ceil((text as NSString).size(withAttributes: [.font: font]).width)
+ let badgeHeight = ceil(fontSize + verticalPadding * 2)
+ let badgeWidth = ceil(textWidth + horizontalPadding * 2)
+
+ let badge = NSTextField(labelWithString: text)
+ badge.font = font
+ badge.textColor = .labelColor
+ badge.alignment = .center
+ badge.lineBreakMode = .byClipping
+ badge.wantsLayer = true
+ badge.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.88).cgColor
+ badge.layer?.cornerRadius = round(5 * scale)
+ badge.layer?.shadowColor = NSColor.black.cgColor
+ badge.layer?.shadowOpacity = 0.22
+ badge.layer?.shadowOffset = CGSize(width: 0, height: -1)
+ badge.layer?.shadowRadius = round(2 * scale)
+ badge.translatesAutoresizingMaskIntoConstraints = false
+ view.addSubview(badge)
+
+ NSLayoutConstraint.activate([
+ badge.trailingAnchor.constraint(equalTo: imageViewObj.trailingAnchor, constant: -inset),
+ badge.bottomAnchor.constraint(equalTo: imageViewObj.bottomAnchor, constant: -inset),
+ badge.widthAnchor.constraint(equalToConstant: badgeWidth),
+ badge.heightAnchor.constraint(equalToConstant: badgeHeight),
+ ])
+
+ folderMediaCountBadgeView = badge
}
func refreshFinderTagDots() {
@@ -452,6 +533,10 @@ class CustomCollectionViewItem: NSCollectionViewItem {
// Bottom-left finder tag dots
refreshFinderTagDots()
+ // 右下角文件夹媒体数量
+ // Bottom-right folder media counts
+ refreshFolderMediaCountBadge()
+
// 右上角HDR/RAW标签
// Top-right corner HDR/RAWlabel
let isShowThumbnailBadge = getViewController(collectionView!)!.publicVar.profile.getValue(forKey: "isShowThumbnailBadge") == "true"
@@ -625,7 +710,7 @@ class CustomCollectionViewItem: NSCollectionViewItem {
tooltipParts.append("\(relativePathLabel): \(relativePath)")
}
- if curFolder.hasPrefix("file:///VirtualFinderTagsFolder") {
+ if isVirtualFolderPath(curFolder) {
let parentDirectoryLabel = NSLocalizedString("Location", comment: "位置")
var parentDirectory = (filePath as NSString).deletingLastPathComponent
if parentDirectory.hasPrefix("file:") {
@@ -705,9 +790,11 @@ class CustomCollectionViewItem: NSCollectionViewItem {
queuePlayer?.removeAllItems()
if let url = URL(string: file.path),
- let timeRange = getCommonTimeRange(url: url) {
+ let viewController = getViewController(collectionView!) {
+ let playbackAsset = viewController.mediaPreheatManager.preheatedAsset(for: url) ?? AVURLAsset(url: url)
+ guard let timeRange = getCommonTimeRange(asset: playbackAsset) else { return }
- let playerItem = AVPlayerItem(url: url)
+ let playerItem = AVPlayerItem(asset: playbackAsset)
queuePlayer?.insert(playerItem, after: nil)
playerLooper = AVPlayerLooper(player: queuePlayer!, templateItem: playerItem, timeRange: timeRange)
queuePlayer?.play()
@@ -1063,6 +1150,8 @@ class CustomCollectionViewItem: NSCollectionViewItem {
if let collectionView = collectionView {
selectedCount=collectionView.selectionIndexPaths.count
}
+ let selectedURLs = getViewController(collectionView!)?.publicVar.selectedUrls() ?? []
+ let isMultiFolderSelection = selectedURLs.count > 1 && selectedURLs.allSatisfy { $0.hasDirectoryPath }
var canPasteOrMove=true
let pasteboard = NSPasteboard.general
@@ -1072,7 +1161,7 @@ class CustomCollectionViewItem: NSCollectionViewItem {
}
let curFolder = getViewController(collectionView!)!.fileDB.curFolder
- let isVirtualFinderTagsFolder = curFolder.hasPrefix("file:///VirtualFinderTagsFolder")
+ let isReadOnlyVirtualFolder = isReadOnlyVirtualFolderPath(curFolder)
// 弹出菜单
// Show context menu
@@ -1105,7 +1194,7 @@ class CustomCollectionViewItem: NSCollectionViewItem {
}
let isRecursive = getViewController(collectionView!)?.publicVar.isRecursiveMode ?? false
- let canShowParent = selectedCount == 1 && (isRecursive || getViewController(collectionView!)!.fileDB.curFolder.hasPrefix("file:///VirtualFinderTagsFolder"))
+ let canShowParent = selectedCount == 1 && (isRecursive || isVirtualFolderPath(getViewController(collectionView!)!.fileDB.curFolder))
if canShowParent, let url = URL(string: file.path) {
let parentURL = url.deletingLastPathComponent()
if !parentURL.path.isEmpty && parentURL.absoluteString != url.absoluteString {
@@ -1132,12 +1221,56 @@ class CustomCollectionViewItem: NSCollectionViewItem {
actionItemGetInfo.keyEquivalentModifierMask = []
menu.addItem(NSMenuItem.separator())
+
+ if selectedCount == 1 {
+ if isFavoritePath(file.path) {
+ menu.addItem(withTitle: NSLocalizedString("Remove from Favorites", comment: "取消收藏"), action: #selector(actRemoveFromFavorites), keyEquivalent: "")
+ } else {
+ menu.addItem(withTitle: NSLocalizedString("Add to Favorites", comment: "添加到收藏"), action: #selector(actAddToFavorites), keyEquivalent: "")
+ }
+ menu.addItem(NSMenuItem.separator())
+ }
+
+ if isMultiFolderSelection {
+ menu.addItem(withTitle: NSLocalizedString("提取子文件夹文件并归集", comment: "提取子文件夹文件并归集"), action: #selector(actCollectFilesFromSubfolders), keyEquivalent: "")
+ menu.addItem(NSMenuItem.separator())
+ }
let actionItemDelete = menu.addItem(withTitle: NSLocalizedString("Move to Trash", comment: "移动到废纸篓"), action: #selector(actDelete), keyEquivalent: "\u{8}")
actionItemDelete.keyEquivalentModifierMask = []
// actionItemDelete.isEnabled = (items.count>0)
+
+ let allSelectedAreArchives = !selectedURLs.isEmpty && selectedURLs.allSatisfy { getViewController(collectionView!)?.isSupportedArchiveURL($0) == true }
+ if allSelectedAreArchives {
+ menu.addItem(withTitle: NSLocalizedString("解压到当前目录", comment: "解压到当前目录"), action: #selector(actExtractArchives), keyEquivalent: "")
+ menu.addItem(withTitle: NSLocalizedString("解压并删除压缩包", comment: "解压并删除压缩包"), action: #selector(actExtractArchivesAndDelete), keyEquivalent: "")
+ }
+
+ let allSelectedAreVideos = !selectedURLs.isEmpty && selectedURLs.allSatisfy {
+ globalVar.HandledVideoExtensions.contains($0.pathExtension.lowercased())
+ }
+ if allSelectedAreVideos {
+ menu.addItem(withTitle: NSLocalizedString("Crop Video Size...", comment: "裁剪视频尺寸..."), action: #selector(actCropSelectedVideos), keyEquivalent: "")
+ }
+ menu.addItem(withTitle: NSLocalizedString("快速压缩", comment: "快速压缩"), action: #selector(actQuickCompress), keyEquivalent: "")
+ menu.addItem(withTitle: NSLocalizedString("压缩为 ZIP", comment: "压缩为 ZIP"), action: #selector(actCompressZip), keyEquivalent: "")
+ menu.addItem(withTitle: NSLocalizedString("压缩并删除源文件", comment: "压缩并删除源文件"), action: #selector(actCompressZipAndDelete), keyEquivalent: "")
+ menu.addItem(withTitle: NSLocalizedString("加密压缩...", comment: "加密压缩..."), action: #selector(actEncryptAndCompress), keyEquivalent: "")
+ if !globalVar.compressionDefaultPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ menu.addItem(withTitle: NSLocalizedString("使用默认密码加密压缩", comment: "使用默认密码加密压缩"), action: #selector(actEncryptCompressWithDefaultPassword), keyEquivalent: "")
+ }
+
menu.addItem(NSMenuItem.separator())
+
+ if selectedCount > 1 {
+ let batchRenameItem = menu.addItem(
+ withTitle: "批量重命名所选项目…",
+ action: #selector(actBatchRenameSelectedItems),
+ keyEquivalent: ""
+ )
+ batchRenameItem.isEnabled = !isReadOnlyVirtualFolder
+ }
let actionItemRename = menu.addItem(withTitle: NSLocalizedString("Rename", comment: "重命名"), action: #selector(actRename), keyEquivalent: "r")
actionItemRename.keyEquivalentModifierMask = []
@@ -1147,17 +1280,16 @@ class CustomCollectionViewItem: NSCollectionViewItem {
let actionItemCopyPath = menu.addItem(withTitle: NSLocalizedString("Copy Path", comment: "复制路径"), action: #selector(actCopyPath), keyEquivalent: "")
let actionItemPaste = menu.addItem(withTitle: NSLocalizedString("Paste", comment: "粘贴"), action: #selector(actPaste), keyEquivalent: "v")
- actionItemPaste.isEnabled = canPasteOrMove && !isVirtualFinderTagsFolder
+ actionItemPaste.isEnabled = canPasteOrMove && !isReadOnlyVirtualFolder
let actionItemMove = menu.addItem(withTitle: NSLocalizedString("Move Here", comment: "移动到此"), action: #selector(actMove), keyEquivalent: "v")
actionItemMove.keyEquivalentModifierMask = [.command,.option]
- actionItemMove.isEnabled = canPasteOrMove && !isVirtualFinderTagsFolder
+ actionItemMove.isEnabled = canPasteOrMove && !isReadOnlyVirtualFolder
let actionItemShare = menu.addItem(withTitle: NSLocalizedString("Share...", comment: "共享..."), action: #selector(actShare(_:)), keyEquivalent: "")
menu.addItem(NSMenuItem.separator())
- let selectedURLs = getViewController(collectionView!)?.publicVar.selectedUrls() ?? []
let tagsPerURL = selectedURLs.map { FinderTagHelper.readTags(from: $0) }
let activeTagNames: Set = {
guard !selectedURLs.isEmpty else { return [] }
@@ -1195,7 +1327,7 @@ class CustomCollectionViewItem: NSCollectionViewItem {
let newMenu = NSMenu()
let newMenuItem = NSMenuItem(title: NSLocalizedString("New", comment: "新建"), action: nil, keyEquivalent: "")
newMenuItem.submenu = newMenu
- newMenuItem.isEnabled = !isVirtualFinderTagsFolder
+ newMenuItem.isEnabled = !isReadOnlyVirtualFolder
// 添加新建文件夹选项
// Add new folder option
@@ -1260,9 +1392,61 @@ class CustomCollectionViewItem: NSCollectionViewItem {
getViewController(collectionView!)?.handleRatingReadme()
}
+ @objc func actAddToFavorites() {
+ if addFavoritePath(file.path) {
+ getViewController(collectionView!)?.refreshTreeView()
+ }
+ }
+
+ @objc func actRemoveFromFavorites() {
+ if removeFavoritePath(file.path) {
+ getViewController(collectionView!)?.refreshTreeView()
+ }
+ }
+
@objc func actRefresh() {
getViewController(collectionView!)?.handleUserRefresh()
}
+
+ @objc func actQuickCompress() {
+ _ = getViewController(collectionView!)?.handleCompressByDefaultSetting()
+ }
+
+ @objc func actExtractArchives() {
+ _ = getViewController(collectionView!)?.handleExtractArchives(deleteOriginal: false)
+ }
+
+ @objc func actExtractArchivesAndDelete() {
+ _ = getViewController(collectionView!)?.handleExtractArchives(deleteOriginal: true)
+ }
+
+ @objc func actCropSelectedVideos() {
+ getViewController(collectionView!)?.handleBatchCropSelectedVideos()
+ }
+
+ @objc func actCompressZip() {
+ _ = getViewController(collectionView!)?.handleCompress(mode: .plainZip, deleteOriginal: false)
+ }
+
+ @objc func actCompressZipAndDelete() {
+ _ = getViewController(collectionView!)?.handleCompress(mode: .plainZip, deleteOriginal: true)
+ }
+
+ @objc func actEncryptAndCompress() {
+ guard let vc = getViewController(collectionView!) else { return }
+ let initial = globalVar.compressionDefaultPassword
+ guard let password = vc.promptCompressionPassword(initialValue: initial) else { return }
+ _ = vc.handleCompress(mode: .encryptedZip(password: password), deleteOriginal: false)
+ }
+
+ @objc func actEncryptCompressWithDefaultPassword() {
+ let password = globalVar.compressionDefaultPassword.trimmingCharacters(in: .whitespacesAndNewlines)
+ if password.isEmpty {
+ showAlert(message: NSLocalizedString("Default compression password is empty.", comment: "默认压缩密码为空。"))
+ return
+ }
+ _ = getViewController(collectionView!)?.handleCompress(mode: .encryptedZip(password: password), deleteOriginal: false)
+ }
@objc func actOpen() {
if let collectionView = collectionView,
@@ -1319,6 +1503,16 @@ class CustomCollectionViewItem: NSCollectionViewItem {
guard let urls = getViewController(collectionView!)?.publicVar.selectedUrls() else { return }
getViewController(collectionView!)?.handleRename(urls: urls);
}
+
+ @objc func actBatchRenameFolders() {
+ guard let viewController = getViewController(collectionView!) else { return }
+ _ = viewController.handleBatchRenameFolders(urls: viewController.publicVar.selectedUrls())
+ }
+
+ @objc func actBatchRenameSelectedItems() {
+ guard let viewController = getViewController(collectionView!) else { return }
+ _ = viewController.handleBatchRenameSelectedItems(urls: viewController.publicVar.selectedUrls())
+ }
@objc func actNewFolder() {
getViewController(collectionView!)?.handleNewFolder()
@@ -1352,6 +1546,10 @@ class CustomCollectionViewItem: NSCollectionViewItem {
getViewController(collectionView!)?.handleDelete(isShowPrompt: false)
}
+ @objc func actCollectFilesFromSubfolders() {
+ _ = getViewController(collectionView!)?.handleCollectFilesFromSubfolders()
+ }
+
@objc func actPaste() {
getViewController(collectionView!)?.handlePaste()
}
diff --git a/FlowVision/Sources/Views/CustomCollectionViewManager.swift b/FlowVision/Sources/Views/CustomCollectionViewManager.swift
index ef641946..de45e81c 100644
--- a/FlowVision/Sources/Views/CustomCollectionViewManager.swift
+++ b/FlowVision/Sources/Views/CustomCollectionViewManager.swift
@@ -7,14 +7,16 @@ import Foundation
import Cocoa
class CustomCollectionViewManager: NSObject, NSCollectionViewDataSource, NSCollectionViewDelegate, NSCollectionViewDelegateFlowLayout {
-
+
var fileDB: DatabaseModel
var lastSelectedIndexPath: IndexPath?
-
+ private var dragURLsByIndexPath: [IndexPath: URL] = [:]
+ private let compactDragPreviewThreshold = 8
+
init(fileDB: DatabaseModel) {
self.fileDB = fileDB
}
-
+
func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int {
fileDB.lock()
defer{fileDB.unlock()}
@@ -23,7 +25,7 @@ class CustomCollectionViewManager: NSObject, NSCollectionViewDataSource, NSColle
}
return 0
}
-
+
func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
let item = collectionView.makeItem(withIdentifier: NSUserInterfaceItemIdentifier(rawValue: "CustomCollectionViewItem"), for: indexPath) as! CustomCollectionViewItem
@@ -32,15 +34,15 @@ class CustomCollectionViewManager: NSObject, NSCollectionViewDataSource, NSColle
item.configureWithImage(file)
}
fileDB.unlock()
-
+
return item
}
-
+
func collectionView(_ collectionView: NSCollectionView, didEndDisplaying item: NSCollectionViewItem, forRepresentedObjectAt indexPath: IndexPath) {
// (item as! ImageCollectionViewItem).imageViewObj?.image?.recache()
// (item as! ImageCollectionViewItem).imageViewObj?.image=nil
}
-
+
func collectionView(_ collectionView: NSCollectionView, didSelectItemsAt indexPaths: Set) {
for indexPath in indexPaths{
// 注意:下面这句当item不在视野内时为nil
@@ -54,8 +56,9 @@ class CustomCollectionViewManager: NSObject, NSCollectionViewDataSource, NSColle
// fileDB.unlock()
}
// log("Selected numbers:"+String(indexPaths.count))
+ getViewController(collectionView)?.publicVar.updateToolbar()
}
-
+
func collectionView(_ collectionView: NSCollectionView, didDeselectItemsAt indexPaths: Set) {
for indexPath in indexPaths {
// 注意:下面这句当item不在视野内时为nil
@@ -71,6 +74,7 @@ class CustomCollectionViewManager: NSObject, NSCollectionViewDataSource, NSColle
// fileDB.unlock()
}
// log("Deselected numbers:"+String(indexPaths.count))
+ getViewController(collectionView)?.publicVar.updateToolbar()
}
func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> NSSize {
fileDB.lock()
@@ -80,28 +84,132 @@ class CustomCollectionViewManager: NSObject, NSCollectionViewDataSource, NSColle
}
return DEFAULT_SIZE
}
-
- func collectionView(_ collectionView: NSCollectionView, pasteboardWriterForItemAt indexPath: IndexPath) -> NSPasteboardWriting? {
+
+ func collectionView(_ collectionView: NSCollectionView, canDragItemsAt indexPaths: Set, with event: NSEvent) -> Bool {
+ dragURLsByIndexPath.removeAll(keepingCapacity: true)
fileDB.lock()
- defer{fileDB.unlock()}
+ defer { fileDB.unlock() }
+ guard let files = fileDB.db[SortKeyDir(fileDB.curFolder)]?.files else { return false }
+
+ for indexPath in indexPaths {
+ guard let path = files.elementSafe(atOffset: indexPath.item)?.1.path,
+ let url = URL(string: path) else { continue }
+ dragURLsByIndexPath[indexPath] = url
+ }
+ return !dragURLsByIndexPath.isEmpty
+ }
+
+ func collectionView(_ collectionView: NSCollectionView, pasteboardWriterForItemAt indexPath: IndexPath) -> NSPasteboardWriting? {
let pasteboardItem = NSPasteboardItem()
- if let path = fileDB.db[SortKeyDir(fileDB.curFolder)]?.files.elementSafe(atOffset: indexPath.item)?.1.path,
- let url = URL(string: path){
+ if let url = dragURLsByIndexPath[indexPath] {
pasteboardItem.setString(url.absoluteString, forType: .fileURL)
+ return pasteboardItem
}
+
+ // Defensive fallback for AppKit versions that request a writer without
+ // first calling canDragItemsAt. Normal multi-selection drags use the cache.
+ fileDB.lock()
+ defer { fileDB.unlock() }
+ guard let path = fileDB.db[SortKeyDir(fileDB.curFolder)]?.files.elementSafe(atOffset: indexPath.item)?.1.path,
+ let url = URL(string: path) else { return nil }
+ pasteboardItem.setString(url.absoluteString, forType: .fileURL)
return pasteboardItem
}
-
+
+ func collectionView(
+ _ collectionView: NSCollectionView,
+ draggingSession session: NSDraggingSession,
+ willBeginAt screenPoint: NSPoint,
+ forItemsAt indexPaths: Set
+ ) {
+ guard indexPaths.count > compactDragPreviewThreshold else {
+ session.draggingFormation = indexPaths.count > 1 ? .stack : .none
+ return
+ }
+
+ session.draggingFormation = .pile
+ session.draggingLeaderIndex = 0
+ session.animatesToStartingPositionsOnCancelOrFail = false
+ let preview = compactDragPreview(itemCount: indexPaths.count)
+ var keptVisibleItem = false
+ session.enumerateDraggingItems(
+ options: [],
+ for: collectionView,
+ classes: [NSPasteboardItem.self],
+ searchOptions: [:]
+ ) { draggingItem, _, _ in
+ if !keptVisibleItem {
+ keptVisibleItem = true
+ let oldFrame = draggingItem.draggingFrame
+ let size = preview.size
+ draggingItem.setDraggingFrame(
+ NSRect(
+ x: oldFrame.midX - size.width / 2,
+ y: oldFrame.midY - size.height / 2,
+ width: size.width,
+ height: size.height
+ ),
+ contents: preview
+ )
+ } else {
+ draggingItem.setDraggingFrame(draggingItem.draggingFrame, contents: nil)
+ }
+ }
+ }
+
+ func collectionView(
+ _ collectionView: NSCollectionView,
+ draggingSession session: NSDraggingSession,
+ endedAt screenPoint: NSPoint,
+ dragOperation operation: NSDragOperation
+ ) {
+ dragURLsByIndexPath.removeAll(keepingCapacity: true)
+ }
+
+ private func compactDragPreview(itemCount: Int) -> NSImage {
+ let size = NSSize(width: 76, height: 62)
+ let image = NSImage(size: size)
+ image.lockFocus()
+
+ let backRect = NSRect(x: 8, y: 7, width: 54, height: 44)
+ NSColor.controlBackgroundColor.withAlphaComponent(0.92).setFill()
+ NSBezierPath(roundedRect: backRect, xRadius: 8, yRadius: 8).fill()
+ NSColor.separatorColor.setStroke()
+ NSBezierPath(roundedRect: backRect, xRadius: 8, yRadius: 8).stroke()
+
+ if let icon = NSImage(named: NSImage.multipleDocumentsName) {
+ icon.draw(in: NSRect(x: 19, y: 15, width: 30, height: 30))
+ }
+
+ let badgeText = itemCount > 999 ? "999+" : String(itemCount)
+ let attributes: [NSAttributedString.Key: Any] = [
+ .font: NSFont.systemFont(ofSize: 11, weight: .semibold),
+ .foregroundColor: NSColor.white
+ ]
+ let textSize = (badgeText as NSString).size(withAttributes: attributes)
+ let badgeWidth = max(24, textSize.width + 10)
+ let badgeRect = NSRect(x: size.width - badgeWidth, y: size.height - 25, width: badgeWidth, height: 21)
+ NSColor.controlAccentColor.setFill()
+ NSBezierPath(roundedRect: badgeRect, xRadius: 10.5, yRadius: 10.5).fill()
+ (badgeText as NSString).draw(
+ at: NSPoint(x: badgeRect.midX - textSize.width / 2, y: badgeRect.midY - textSize.height / 2),
+ withAttributes: attributes
+ )
+
+ image.unlockFocus()
+ return image
+ }
+
func collectionView(_ collectionView: NSCollectionView, shouldSelectItemsAt indexPaths: Set) -> Set {
guard let indexPath = indexPaths.first else { return [] }
-
+
// Check if the Shift key is pressed or no selection
if NSEvent.modifierFlags.contains(.shift), let lastIndexPath = lastSelectedIndexPath, collectionView.selectionIndexPaths.count >= 1 {
// Calculate the range of items to select
let startIndex = min(lastIndexPath.item, indexPath.item)
let endIndex = max(lastIndexPath.item, indexPath.item)
let indexSet = IndexSet(startIndex...endIndex)
-
+
// Create new index paths for the range
let newSelectedIndexPaths = indexSet.map { IndexPath(item: $0, section: indexPath.section) }
return Set(newSelectedIndexPaths)
@@ -111,14 +219,13 @@ class CustomCollectionViewManager: NSObject, NSCollectionViewDataSource, NSColle
return indexPaths
}
}
-
+
func collectionView(_ collectionView: NSCollectionView, shouldDeselectItemsAt indexPaths: Set) -> Set {
guard let indexPath = indexPaths.first else { return [] }
-
+
// TODO
-
+
return indexPaths
}
}
-
diff --git a/FlowVision/Sources/Views/CustomOutlineView.swift b/FlowVision/Sources/Views/CustomOutlineView.swift
index 217eaa9e..5ffd970e 100644
--- a/FlowVision/Sources/Views/CustomOutlineView.swift
+++ b/FlowVision/Sources/Views/CustomOutlineView.swift
@@ -105,10 +105,18 @@ class CustomOutlineView: NSOutlineView, NSMenuDelegate {
actionItemOpenInNewTab.isEnabled=true
}
- if curRightClickedPath.hasPrefix("file:///VirtualFinderTagsFolder") {
+ if isReadOnlyVirtualFolderPath(curRightClickedPath) {
} else {
+ menu.addItem(NSMenuItem.separator())
+
+ if isFavoritePath(curRightClickedPath) {
+ menu.addItem(withTitle: NSLocalizedString("Remove from Favorites", comment: "取消收藏"), action: #selector(actRemoveFromFavorites), keyEquivalent: "")
+ } else {
+ menu.addItem(withTitle: NSLocalizedString("Add to Favorites", comment: "添加到收藏"), action: #selector(actAddToFavorites), keyEquivalent: "")
+ }
+
menu.addItem(NSMenuItem.separator())
menu.addItem(withTitle: NSLocalizedString("Show in Finder", comment: "在Finder中显示"), action: #selector(actShowInFinder), keyEquivalent: "")
@@ -149,8 +157,34 @@ class CustomOutlineView: NSOutlineView, NSMenuDelegate {
let actionItemDelete = menu.addItem(withTitle: NSLocalizedString("Move to Trash", comment: "移动到废纸篓"), action: #selector(actDelete), keyEquivalent: "\u{8}")
actionItemDelete.keyEquivalentModifierMask = []
+
+ if let archiveURL = URL(string: curRightClickedPath),
+ getViewController(self)?.isSupportedArchiveURL(archiveURL) == true {
+ menu.addItem(withTitle: NSLocalizedString("解压到当前目录", comment: "解压到当前目录"), action: #selector(actExtractArchive), keyEquivalent: "")
+ menu.addItem(withTitle: NSLocalizedString("解压并删除压缩包", comment: "解压并删除压缩包"), action: #selector(actExtractArchiveAndDelete), keyEquivalent: "")
+ }
+ menu.addItem(withTitle: NSLocalizedString("快速压缩", comment: "快速压缩"), action: #selector(actQuickCompress), keyEquivalent: "")
+ menu.addItem(withTitle: NSLocalizedString("压缩为 ZIP", comment: "压缩为 ZIP"), action: #selector(actCompressZip), keyEquivalent: "")
+ menu.addItem(withTitle: NSLocalizedString("压缩并删除源文件", comment: "压缩并删除源文件"), action: #selector(actCompressZipAndDelete), keyEquivalent: "")
+ menu.addItem(withTitle: NSLocalizedString("加密压缩...", comment: "加密压缩..."), action: #selector(actEncryptAndCompress), keyEquivalent: "")
+ if !globalVar.compressionDefaultPassword.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ menu.addItem(withTitle: NSLocalizedString("使用默认密码加密压缩", comment: "使用默认密码加密压缩"), action: #selector(actEncryptCompressWithDefaultPassword), keyEquivalent: "")
+ }
+
menu.addItem(NSMenuItem.separator())
+
+ let selectedURLs = selectedRowIndexes.compactMap { row -> URL? in
+ guard let selectedItem = self.item(atRow: row) as? TreeNode else { return nil }
+ return URL(string: selectedItem.fullPath)
+ }
+ if selectedURLs.count > 1 {
+ menu.addItem(
+ withTitle: "批量重命名所选项目…",
+ action: #selector(actBatchRenameSelectedItems),
+ keyEquivalent: ""
+ )
+ }
let actionItemRename = menu.addItem(withTitle: NSLocalizedString("Rename", comment: "重命名"), action: #selector(actRename), keyEquivalent: "r")
actionItemRename.keyEquivalentModifierMask = []
@@ -304,6 +338,16 @@ class CustomOutlineView: NSOutlineView, NSMenuDelegate {
refreshTreeView()
}
}
+
+ @objc func actBatchRenameSelectedItems() {
+ let urls = selectedRowIndexes.compactMap { row -> URL? in
+ guard let item = self.item(atRow: row) as? TreeNode else { return nil }
+ return URL(string: item.fullPath)
+ }
+ guard let viewController = getViewController(self), urls.count > 1 else { return }
+ _ = viewController.handleBatchRenameSelectedItems(urls: urls)
+ refreshTreeView()
+ }
@objc func actNewFolder() {
guard let url=URL(string: curRightClickedPath) else{return}
@@ -391,6 +435,59 @@ class CustomOutlineView: NSOutlineView, NSMenuDelegate {
task.launch()
}
+ @objc func actQuickCompress() {
+ guard let vc = getViewController(self), let url = URL(string: curRightClickedPath) else { return }
+ _ = vc.handleCompressByDefaultSetting(urls: [url], deleteOriginal: false)
+ }
+
+ @objc func actExtractArchive() {
+ guard let vc = getViewController(self), let url = URL(string: curRightClickedPath) else { return }
+ _ = vc.handleExtractArchives(urls: [url], deleteOriginal: false)
+ }
+
+ @objc func actExtractArchiveAndDelete() {
+ guard let vc = getViewController(self), let url = URL(string: curRightClickedPath) else { return }
+ _ = vc.handleExtractArchives(urls: [url], deleteOriginal: true)
+ }
+
+ @objc func actCompressZip() {
+ guard let vc = getViewController(self), let url = URL(string: curRightClickedPath) else { return }
+ _ = vc.handleCompress(urls: [url], mode: .plainZip, deleteOriginal: false)
+ }
+
+ @objc func actCompressZipAndDelete() {
+ guard let vc = getViewController(self), let url = URL(string: curRightClickedPath) else { return }
+ _ = vc.handleCompress(urls: [url], mode: .plainZip, deleteOriginal: true)
+ }
+
+ @objc func actEncryptAndCompress() {
+ guard let vc = getViewController(self), let url = URL(string: curRightClickedPath) else { return }
+ guard let password = vc.promptCompressionPassword(initialValue: globalVar.compressionDefaultPassword) else { return }
+ _ = vc.handleCompress(urls: [url], mode: .encryptedZip(password: password), deleteOriginal: false)
+ }
+
+ @objc func actEncryptCompressWithDefaultPassword() {
+ guard let vc = getViewController(self), let url = URL(string: curRightClickedPath) else { return }
+ let password = globalVar.compressionDefaultPassword.trimmingCharacters(in: .whitespacesAndNewlines)
+ if password.isEmpty {
+ showAlert(message: NSLocalizedString("Default compression password is empty.", comment: "默认压缩密码为空。"))
+ return
+ }
+ _ = vc.handleCompress(urls: [url], mode: .encryptedZip(password: password), deleteOriginal: false)
+ }
+
+ @objc func actAddToFavorites() {
+ if addFavoritePath(curRightClickedPath) {
+ refreshTreeView()
+ }
+ }
+
+ @objc func actRemoveFromFavorites() {
+ if removeFavoritePath(curRightClickedPath) {
+ refreshTreeView()
+ }
+ }
+
@objc func actToggleFinderTag(_ sender: NSMenuItem) {
guard let tagName = sender.representedObject as? String,
let url = URL(string: curRightClickedPath) else { return }
diff --git a/FlowVision/Sources/Views/CustomOutlineViewManager.swift b/FlowVision/Sources/Views/CustomOutlineViewManager.swift
index 3fb8832d..32e605b0 100644
--- a/FlowVision/Sources/Views/CustomOutlineViewManager.swift
+++ b/FlowVision/Sources/Views/CustomOutlineViewManager.swift
@@ -68,6 +68,16 @@ extension CustomOutlineViewManager: NSOutlineViewDelegate {
if treeNode.fullPath.contains("FlowVisionTitleFolder") {
view.imageView?.image = NSImage(named: "AppIcon")
view.imageView?.contentTintColor = nil
+ } else if treeNode.fullPath.hasPrefix(VIRTUAL_FAVORITES_PREFIX) {
+ let icon = NSImage(systemSymbolName: "star.fill", accessibilityDescription: nil)?
+ .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 14, weight: .regular))
+ view.imageView?.image = icon
+ view.imageView?.contentTintColor = .secondaryLabelColor
+ } else if treeNode.fullPath.hasPrefix(VIRTUAL_HISTORY_PREFIX) {
+ let icon = NSImage(systemSymbolName: "clock.arrow.circlepath", accessibilityDescription: nil)?
+ .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 14, weight: .regular))
+ view.imageView?.image = icon
+ view.imageView?.contentTintColor = .secondaryLabelColor
} else if treeNode.fullPath.hasPrefix("file:///VirtualFinderTagsFolder") {
let tagIcon = NSImage(systemSymbolName: "tag.fill", accessibilityDescription: nil)?
.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 14, weight: .regular))
@@ -207,7 +217,7 @@ extension CustomOutlineViewManager: NSOutlineViewDelegate {
}
func outlineView(_ outlineView: NSOutlineView, validateDrop info: NSDraggingInfo, proposedItem item: Any?, proposedChildIndex index: Int) -> NSDragOperation {
- if let node = item as? TreeNode, node.fullPath.hasPrefix("file:///VirtualFinderTagsFolder") {
+ if let node = item as? TreeNode, isReadOnlyVirtualFolderPath(node.fullPath) {
return []
}
return .move
@@ -253,7 +263,7 @@ extension CustomOutlineViewManager: NSOutlineViewDelegate {
func outlineView(_ outlineView: NSOutlineView, pasteboardWriterForItem item: Any) -> NSPasteboardWriting? {
guard let outlineItem = item as? TreeNode else { return nil }
- if outlineItem.fullPath.hasPrefix("file:///VirtualFinderTagsFolder") { return nil }
+ if isReadOnlyVirtualFolderPath(outlineItem.fullPath) { return nil }
let pasteboardItem = NSPasteboardItem()
diff --git a/FlowVision/Sources/Views/LargeImageView.swift b/FlowVision/Sources/Views/LargeImageView.swift
index 8136c517..42e1f3da 100644
--- a/FlowVision/Sources/Views/LargeImageView.swift
+++ b/FlowVision/Sources/Views/LargeImageView.swift
@@ -8,12 +8,145 @@ import Cocoa
import VisionKit
import AVKit
+private final class VideoCropOverlayView: NSView {
+ private let actionButtonSize: CGFloat = 26
+ private let actionButtonGap: CGFloat = 6
+
+ var selectionRect: NSRect = .zero {
+ didSet { needsDisplay = true }
+ }
+
+ override var isFlipped: Bool { false }
+
+ override func hitTest(_ point: NSPoint) -> NSView? {
+ nil
+ }
+
+ override func draw(_ dirtyRect: NSRect) {
+ super.draw(dirtyRect)
+
+ NSColor.black.withAlphaComponent(0.45).setFill()
+ if selectionRect.isEmpty {
+ bounds.fill()
+ return
+ }
+
+ NSRect(x: bounds.minX, y: bounds.minY, width: bounds.width, height: max(0, selectionRect.minY - bounds.minY)).fill()
+ NSRect(x: bounds.minX, y: selectionRect.maxY, width: bounds.width, height: max(0, bounds.maxY - selectionRect.maxY)).fill()
+ NSRect(x: bounds.minX, y: selectionRect.minY, width: max(0, selectionRect.minX - bounds.minX), height: selectionRect.height).fill()
+ NSRect(x: selectionRect.maxX, y: selectionRect.minY, width: max(0, bounds.maxX - selectionRect.maxX), height: selectionRect.height).fill()
+
+ NSColor.systemYellow.setStroke()
+ let border = NSBezierPath(rect: selectionRect)
+ border.lineWidth = 2
+ border.stroke()
+
+ NSColor.systemYellow.setFill()
+ for handle in handleRects(for: selectionRect) {
+ let path = NSBezierPath(roundedRect: handle, xRadius: 2, yRadius: 2)
+ path.fill()
+ }
+
+ drawActionButtons()
+ }
+
+ func confirmButtonRect() -> NSRect {
+ guard !selectionRect.isEmpty else { return .zero }
+ let y = max(bounds.minY + actionButtonGap, selectionRect.minY + actionButtonGap)
+ let x = min(bounds.maxX - actionButtonSize - actionButtonGap, selectionRect.maxX - actionButtonSize - actionButtonGap)
+ return NSRect(x: x, y: y, width: actionButtonSize, height: actionButtonSize)
+ }
+
+ func cancelButtonRect() -> NSRect {
+ let confirm = confirmButtonRect()
+ guard !confirm.isEmpty else { return .zero }
+ return confirm.offsetBy(dx: -(actionButtonSize + actionButtonGap), dy: 0)
+ }
+
+ private func drawActionButtons() {
+ let confirm = confirmButtonRect()
+ let cancel = cancelButtonRect()
+ guard !confirm.isEmpty, !cancel.isEmpty else { return }
+
+ drawButtonBackground(cancel, color: NSColor.systemRed.withAlphaComponent(0.92))
+ drawButtonBackground(confirm, color: NSColor.systemGreen.withAlphaComponent(0.92))
+ drawX(in: cancel)
+ drawCheckmark(in: confirm)
+ }
+
+ private func drawButtonBackground(_ rect: NSRect, color: NSColor) {
+ color.setFill()
+ NSBezierPath(roundedRect: rect, xRadius: 5, yRadius: 5).fill()
+ }
+
+ private func drawX(in rect: NSRect) {
+ NSColor.white.setStroke()
+ let path = NSBezierPath()
+ path.lineWidth = 2.4
+ path.lineCapStyle = .round
+ path.move(to: NSPoint(x: rect.minX + 8, y: rect.minY + 8))
+ path.line(to: NSPoint(x: rect.maxX - 8, y: rect.maxY - 8))
+ path.move(to: NSPoint(x: rect.maxX - 8, y: rect.minY + 8))
+ path.line(to: NSPoint(x: rect.minX + 8, y: rect.maxY - 8))
+ path.stroke()
+ }
+
+ private func drawCheckmark(in rect: NSRect) {
+ NSColor.white.setStroke()
+ let path = NSBezierPath()
+ path.lineWidth = 2.6
+ path.lineCapStyle = .round
+ path.lineJoinStyle = .round
+ path.move(to: NSPoint(x: rect.minX + 7, y: rect.midY))
+ path.line(to: NSPoint(x: rect.midX - 1, y: rect.minY + 8))
+ path.line(to: NSPoint(x: rect.maxX - 7, y: rect.maxY - 8))
+ path.stroke()
+ }
+
+ private func handleRects(for rect: NSRect) -> [NSRect] {
+ let size: CGFloat = 8
+ let half = size / 2
+ let points = [
+ NSPoint(x: rect.minX, y: rect.minY),
+ NSPoint(x: rect.midX, y: rect.minY),
+ NSPoint(x: rect.maxX, y: rect.minY),
+ NSPoint(x: rect.minX, y: rect.midY),
+ NSPoint(x: rect.maxX, y: rect.midY),
+ NSPoint(x: rect.minX, y: rect.maxY),
+ NSPoint(x: rect.midX, y: rect.maxY),
+ NSPoint(x: rect.maxX, y: rect.maxY)
+ ]
+ return points.map { NSRect(x: $0.x - half, y: $0.y - half, width: size, height: size) }
+ }
+}
+
+private enum VideoCropDragMode {
+ case new
+ case move
+ case resizeLeft
+ case resizeRight
+ case resizeTop
+ case resizeBottom
+ case resizeTopLeft
+ case resizeTopRight
+ case resizeBottomLeft
+ case resizeBottomRight
+}
+
+private enum VideoCropActionButton {
+ case confirm
+ case cancel
+}
+
class LargeImageView: NSView {
var imageView: CustomLargeImageView!
var snapshotQueue = [NSView?]()
var videoView: LargeAVPlayerView!
+ var mpvVideoView: FlowMPVVideoView!
+ var mpvPlayer: MPVPlayerBackend?
+ var isUsingMPVPlayer = false
// var videoPlayer: AVPlayer?
var playerItem: AVPlayerItem?
var queuePlayer: AVQueuePlayer?
@@ -35,6 +168,17 @@ class LargeImageView: NSView {
private var volumeObservation: NSKeyValueObservation?
private var blackOverlayView: NSView?
+ private var isSelectingVideoCrop = false
+ private var videoCropStartPoint: NSPoint?
+ private var videoCropSelectionRect: NSRect = .zero
+ private var videoCropOverlayView: VideoCropOverlayView?
+ private var wasPlayingBeforeVideoCropSelection = false
+ private var videoCropDragMode: VideoCropDragMode?
+ private var videoCropDragOriginalRect: NSRect = .zero
+ private var pendingVideoCropActionButton: VideoCropActionButton?
+ var isInVideoCropSelectionMode: Bool {
+ isSelectingVideoCrop
+ }
var videoControlsView: VideoPlayerControlsView!
private var periodicTimeObserver: Any?
@@ -101,6 +245,8 @@ class LargeImageView: NSView {
}
private func commonInit() {
+ wantsLayer = true
+
imageView = CustomLargeImageView(frame: self.bounds)
imageView.imageScaling = .scaleAxesIndependently
imageView.wantsLayer = true
@@ -116,6 +262,11 @@ class LargeImageView: NSView {
videoView.videoGravity = .resizeAspect
videoView.isHidden = true
self.addSubview(videoView)
+
+ mpvVideoView = FlowMPVVideoView(frame: self.bounds)
+ mpvVideoView.autoresizingMask = [.width, .height]
+ mpvVideoView.isHidden = true
+ self.addSubview(mpvVideoView)
volumeObservation = queuePlayer?.observe(\.volume, options: [.new, .old]) { [weak self] _, change in
guard let self = self,
@@ -582,8 +733,72 @@ class LargeImageView: NSView {
self.trackingAreas.forEach { self.removeTrackingArea($0) }
setupMouseTracking()
}
+
+ var videoCurrentTimeSeconds: Double {
+ if isUsingMPVPlayer {
+ return mpvPlayer?.currentTime ?? 0
+ }
+ return CMTimeGetSeconds(queuePlayer?.currentTime() ?? .zero)
+ }
+
+ var videoDurationSeconds: Double {
+ if isUsingMPVPlayer {
+ return mpvPlayer?.duration ?? 0
+ }
+ return CMTimeGetSeconds(queuePlayer?.currentItem?.duration ?? .zero)
+ }
+
+ var videoIsPlaying: Bool {
+ if isUsingMPVPlayer {
+ return mpvPlayer?.isPlaying == true
+ }
+ return queuePlayer?.rate ?? 0 > 0
+ }
+
+ var videoVolume: Float {
+ get {
+ if isUsingMPVPlayer {
+ return mpvPlayer?.volume ?? globalVar.videoVolume
+ }
+ return queuePlayer?.volume ?? globalVar.videoVolume
+ }
+ set {
+ let bounded = max(0, min(1, newValue))
+ if isUsingMPVPlayer {
+ mpvPlayer?.volume = bounded
+ saveVolumeChange()
+ videoControlsView.updateVolumeUI()
+ } else {
+ queuePlayer?.volume = bounded
+ }
+ }
+ }
+
+ func seekVideo(to seconds: Double) {
+ if isUsingMPVPlayer {
+ mpvPlayer?.seek(to: seconds)
+ } else {
+ let targetTime = CMTimeMakeWithSeconds(seconds, preferredTimescale: 600)
+ queuePlayer?.seek(to: targetTime, toleranceBefore: .zero, toleranceAfter: .zero)
+ }
+ }
+
+ func setVideoPaused(_ paused: Bool) {
+ if isUsingMPVPlayer {
+ mpvPlayer?.setPaused(paused)
+ } else if paused {
+ queuePlayer?.pause()
+ } else {
+ queuePlayer?.rate = globalVar.videoPlaybackRate
+ }
+ }
func pauseOrResumeVideo() {
+ if isUsingMPVPlayer {
+ setVideoPaused(videoIsPlaying)
+ videoControlsView.updatePlayPauseIcon()
+ return
+ }
if let queuePlayer = queuePlayer {
if queuePlayer.timeControlStatus == .playing {
queuePlayer.pause()
@@ -595,6 +810,10 @@ class LargeImageView: NSView {
}
func pauseVideo() {
+ if isUsingMPVPlayer {
+ setVideoPaused(true)
+ return
+ }
if let queuePlayer = queuePlayer {
if queuePlayer.timeControlStatus == .playing {
queuePlayer.pause()
@@ -603,6 +822,10 @@ class LargeImageView: NSView {
}
func resumeVideo() {
+ if isUsingMPVPlayer {
+ setVideoPaused(false)
+ return
+ }
if let queuePlayer = queuePlayer {
if queuePlayer.timeControlStatus == .paused {
queuePlayer.rate = globalVar.videoPlaybackRate
@@ -611,8 +834,8 @@ class LargeImageView: NSView {
}
func specifyABPlayPositionA(){
- if let queuePlayer = queuePlayer {
- abPlayPositionA = queuePlayer.currentTime()
+ if isUsingMPVPlayer || queuePlayer != nil {
+ abPlayPositionA = CMTime(seconds: videoCurrentTimeSeconds, preferredTimescale: 600)
videoControlsView.updateABMarkers()
if abPlayPositionA != nil && abPlayPositionB != nil {
if CMTimeGetSeconds(abPlayPositionA!) > CMTimeGetSeconds(abPlayPositionB!) {
@@ -628,8 +851,8 @@ class LargeImageView: NSView {
}
func specifyABPlayPositionB(){
- if let queuePlayer = queuePlayer {
- abPlayPositionB = queuePlayer.currentTime()
+ if isUsingMPVPlayer || queuePlayer != nil {
+ abPlayPositionB = CMTime(seconds: videoCurrentTimeSeconds, preferredTimescale: 600)
videoControlsView.updateABMarkers()
if abPlayPositionA != nil && abPlayPositionB != nil {
if CMTimeGetSeconds(abPlayPositionA!) > CMTimeGetSeconds(abPlayPositionB!) {
@@ -646,7 +869,7 @@ class LargeImageView: NSView {
func specifyABPlayPositionAuto(){
if file.type != .video {return}
- if let queuePlayer = queuePlayer {
+ if isUsingMPVPlayer || queuePlayer != nil {
if abPlayPositionA == nil {
specifyABPlayPositionA()
} else if abPlayPositionB == nil {
@@ -659,9 +882,8 @@ class LargeImageView: NSView {
func saveCurrentPlayPosition(){
if globalVar.videoPlayRememberPosition,
- let currentURL = currentPlayingURL,
- let currentTime = queuePlayer?.currentTime() {
- UserDefaults.standard.set(currentTime.seconds, forKey: "videoPosition_\(currentURL.absoluteString)")
+ let currentURL = currentPlayingURL {
+ UserDefaults.standard.set(videoCurrentTimeSeconds, forKey: "videoPosition_\(currentURL.absoluteString)")
}
}
@@ -669,7 +891,7 @@ class LargeImageView: NSView {
if globalVar.videoPlayRememberPosition {
saveCurrentPlayPosition()
}
- restorePlayPosition = savePosition ? queuePlayer?.currentTime() : nil
+ restorePlayPosition = savePosition ? CMTime(seconds: videoCurrentTimeSeconds, preferredTimescale: 600) : nil
restorePlayURL = savePosition ? currentPlayingURL : nil
if !savePosition {
abPlayPositionA = nil
@@ -677,8 +899,12 @@ class LargeImageView: NSView {
}
videoOrderId += 1
videoView.isHidden = true
+ mpvVideoView.isHidden = true
videoControlsView.hideControlsImmediately()
stopPeriodicTimeObserver()
+ mpvPlayer?.stop()
+ mpvPlayer = nil
+ isUsingMPVPlayer = false
hideUnsupportedVideoOverlay()
if let observer = videoEndObserver {
NotificationCenter.default.removeObserver(observer)
@@ -732,7 +958,7 @@ class LargeImageView: NSView {
}
if reload || reloadForAB {
- restorePlayPosition = queuePlayer?.currentTime()
+ restorePlayPosition = CMTime(seconds: videoCurrentTimeSeconds, preferredTimescale: 600)
restorePlayURL = currentPlayingURL
}
@@ -740,13 +966,17 @@ class LargeImageView: NSView {
NotificationCenter.default.removeObserver(observer)
videoEndObserver = nil
}
+ mpvPlayer?.stop()
+ mpvPlayer = nil
+ isUsingMPVPlayer = false
+ mpvVideoView.isHidden = true
playerLooper?.disableLooping()
playerLooper = nil
queuePlayer?.removeAllItems()
playerItem = nil
videoView.controlsStyle = .none
videoOrderId += 1
- videoView.isHidden = false
+ videoView.isHidden = true
pausedBySeek = false
isVideoMetadataUpdated = false
if !reloadForAB {
@@ -760,10 +990,49 @@ class LargeImageView: NSView {
updateVideoMetadata(url: url)
}
- if let timeRange = getCommonTimeRange(url: url) {
- playerItem = AVPlayerItem(url: url)
+ var finalABRange: ClosedRange?
+ if let positionA = abPlayPositionA?.seconds,
+ let positionB = abPlayPositionB?.seconds,
+ positionA < positionB {
+ finalABRange = positionA...positionB
+ }
+
+ if let mpvPlayer = MPVPlayerBackend(renderView: mpvVideoView) {
+ let shouldLoop = !(globalVar.videoPlaySequentialPlay && abPlayPositionA == nil && abPlayPositionB == nil)
+ let didLoad = mpvPlayer.load(
+ url: url,
+ startTime: restorePlayURL == url ? restorePlayPosition?.seconds : nil,
+ volume: globalVar.videoVolume,
+ rate: globalVar.videoPlaybackRate,
+ rotation: file.rotate,
+ abRange: finalABRange,
+ loop: shouldLoop,
+ endHandler: { [weak self] in
+ guard let self = self else { return }
+ if globalVar.videoPlaySequentialPlay && self.abPlayPositionA == nil && self.abPlayPositionB == nil {
+ getViewController(self)?.nextLargeImage(isShowReachEndPrompt: true, firstShowThumb: true)
+ }
+ }
+ )
+ if didLoad {
+ self.mpvPlayer = mpvPlayer
+ isUsingMPVPlayer = true
+ currentPlayingURL = url
+ mpvVideoView.isHidden = false
+ startPeriodicTimeObserver()
+ checkPlayerItemStatus(id: videoOrderId)
+ return
+ }
+ }
+
+ videoView.isHidden = false
+ let playbackAsset = getViewController(self)?.mediaPreheatManager.preheatedAsset(for: url) ?? AVURLAsset(url: url)
+ if let timeRange = getCommonTimeRange(asset: playbackAsset) {
+ playerItem = AVPlayerItem(asset: playbackAsset)
if let playerItem = playerItem,
let queuePlayer = queuePlayer {
+ playerItem.preferredForwardBufferDuration = 5
+ queuePlayer.automaticallyWaitsToMinimizeStalling = true
// 根据 file.rotate 设置视频旋转角度
// Set video rotation angle based on file.rotate
@@ -781,7 +1050,12 @@ class LargeImageView: NSView {
composition.renderSize = rotation == 90 || rotation == 270 ?
CGSize(width: videoTrack.naturalSize.height, height: videoTrack.naturalSize.width) :
videoTrack.naturalSize
- composition.frameDuration = CMTime(value: 1, timescale: 30)
+ let frameRate = videoTrack.nominalFrameRate
+ if frameRate > 0 {
+ composition.frameDuration = CMTime(value: 1000, timescale: CMTimeScale(frameRate * 1000))
+ } else {
+ composition.frameDuration = CMTime(value: 1, timescale: 60)
+ }
let instruction = AVMutableVideoCompositionInstruction()
instruction.timeRange = CMTimeRange(start: .zero, duration: .positiveInfinity)
@@ -869,8 +1143,31 @@ class LargeImageView: NSView {
private func checkPlayerItemStatus(id: Int) {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.01) { [weak self] in
- guard let self = self, let playerItem = self.playerItem else { return }
+ guard let self = self else { return }
if id != videoOrderId { return }
+
+ if isUsingMPVPlayer {
+ if mpvPlayer?.duration ?? 0 > 0 || mpvPlayer?.currentTime ?? 0 > 0 {
+ restorePlayPosition = nil
+ restorePlayURL = nil
+ while snapshotQueue.count > 0{
+ snapshotQueue.first??.removeFromSuperview()
+ snapshotQueue.removeFirst()
+ }
+ if abPlayPositionA != nil && abPlayPositionB != nil && lastActionTriggerdReload == "ABPlay" {
+ showInfo(NSLocalizedString("A-B Loop Active", comment: "(视频)A-B循环启用"))
+ lastActionTriggerdReload = nil
+ } else if lastActionTriggerdReload == "Rotate" {
+ showInfo(String(format: NSLocalizedString("Rotate %d°", comment: "(视频)旋转%d°"), file.rotate*90))
+ lastActionTriggerdReload = nil
+ }
+ } else {
+ checkPlayerItemStatus(id: id)
+ }
+ return
+ }
+
+ guard self.playerItem != nil else { return }
// log("playerItem.status: ", playerItem.status.rawValue)
@@ -941,20 +1238,15 @@ class LargeImageView: NSView {
// return
// }
- guard let player = queuePlayer else {
- return
- }
-
- // 获取视频总时长
- // Get total video duration
- guard let duration = player.currentItem?.duration else {
+ let durationSeconds = videoDurationSeconds
+ guard durationSeconds.isFinite && durationSeconds > 0 else {
return
}
// 计算实际可播放时长
// Calculate actual playable duration
var startTime: Double = 0
- var endTime = CMTimeGetSeconds(duration)
+ var endTime = durationSeconds
// 如果设置了AB播放点,使用AB点之间的时长
// If AB playback points are set, use duration between AB points
@@ -977,8 +1269,7 @@ class LargeImageView: NSView {
// 获取当前播放时间
// Get current playback time
- let currentTime = player.currentTime()
- let currentSeconds = CMTimeGetSeconds(currentTime)
+ let currentSeconds = videoCurrentTimeSeconds
// 计算目标时间,确保在有效范围内
// Calculate target time, ensure within valid range
@@ -990,22 +1281,28 @@ class LargeImageView: NSView {
CMTimeGetSeconds(abPlayPositionA!) < CMTimeGetSeconds(abPlayPositionB!) {
targetSeconds = max(startTime, min(endTime, targetSeconds))
} else {
- targetSeconds = max(0, min(CMTimeGetSeconds(duration), targetSeconds))
+ targetSeconds = max(0, min(durationSeconds, targetSeconds))
}
// 暂停
// Pause
- if player.timeControlStatus == .playing {
+ if videoIsPlaying {
pausedBySeek = true
pauseVideo()
}
- // 转换为CMTime并执行跳转
- let targetTime = CMTimeMakeWithSeconds(Float64(targetSeconds), preferredTimescale: 600)
- player.seek(to: targetTime, toleranceBefore: .zero, toleranceAfter: .zero)
+ seekVideo(to: targetSeconds)
}
func seekVideoByFrame(direction: Int) {
+ if isUsingMPVPlayer {
+ let fps = 60.0
+ let seekDuration = direction > 0 ? 1.0 / fps : -1.0 / fps
+ pauseVideo()
+ seekVideo(to: videoCurrentTimeSeconds + seekDuration)
+ return
+ }
+
guard let player = queuePlayer,
let asset = player.currentItem?.asset else {
return
@@ -1039,8 +1336,7 @@ class LargeImageView: NSView {
targetSeconds = max(0, min(CMTimeGetSeconds(duration), targetSeconds))
}
- let targetTime = CMTimeMakeWithSeconds(targetSeconds, preferredTimescale: 600)
- player.seek(to: targetTime, toleranceBefore: .zero, toleranceAfter: .zero)
+ seekVideo(to: targetSeconds)
// 显示帧信息
// Display frame information
@@ -1049,14 +1345,12 @@ class LargeImageView: NSView {
}
func seekVideo(direction: Int) {
- guard let player = queuePlayer,
- let duration = player.currentItem?.duration else {
+ let totalSeconds = videoDurationSeconds
+ guard totalSeconds.isFinite && totalSeconds > 0 else {
return
}
- let totalSeconds = CMTimeGetSeconds(duration)
- let currentTime = player.currentTime()
- let currentSeconds = CMTimeGetSeconds(currentTime)
+ let currentSeconds = videoCurrentTimeSeconds
var minBound = 0.0
var maxBound = totalSeconds
@@ -1076,19 +1370,16 @@ class LargeImageView: NSView {
var targetSeconds = currentSeconds + seconds
targetSeconds = max(minBound, min(maxBound, targetSeconds))
- let targetTime = CMTimeMakeWithSeconds(Float64(targetSeconds), preferredTimescale: 600)
- player.seek(to: targetTime, toleranceBefore: .zero, toleranceAfter: .zero)
+ seekVideo(to: targetSeconds)
}
func seekVideoBySeconds(seconds: Double) {
- guard let player = queuePlayer,
- let duration = player.currentItem?.duration else {
+ let totalSeconds = videoDurationSeconds
+ guard totalSeconds.isFinite && totalSeconds > 0 else {
return
}
- let totalSeconds = CMTimeGetSeconds(duration)
- let currentTime = player.currentTime()
- let currentSeconds = CMTimeGetSeconds(currentTime)
+ let currentSeconds = videoCurrentTimeSeconds
var minBound = 0.0
var maxBound = totalSeconds
@@ -1101,16 +1392,13 @@ class LargeImageView: NSView {
var targetSeconds = currentSeconds + seconds
targetSeconds = max(minBound, min(maxBound, targetSeconds))
- let targetTime = CMTimeMakeWithSeconds(Float64(targetSeconds), preferredTimescale: 600)
- player.seek(to: targetTime, toleranceBefore: .zero, toleranceAfter: .zero)
+ seekVideo(to: targetSeconds)
}
func adjustVolume(by delta: Float) {
- guard let player = queuePlayer else { return }
-
// 获取当前音量并计算新音量
// Get current volume and calculate new volume
- var newVolume = round((player.volume + delta) * 100) / 100
+ var newVolume = round((videoVolume + delta) * 100) / 100
// 限制音量在0-1之间
// Limit volume between 0-1
@@ -1118,7 +1406,7 @@ class LargeImageView: NSView {
// 设置新音量
// Set new volume
- player.volume = newVolume
+ videoVolume = newVolume
// 显示音量信息
// Display volume information
@@ -1135,8 +1423,7 @@ class LargeImageView: NSView {
}
func saveVolumeChange() {
- guard let player = queuePlayer else { return }
- globalVar.videoVolume = player.volume
+ globalVar.videoVolume = videoVolume
UserDefaults.standard.set(globalVar.videoVolume, forKey: "videoVolume")
}
@@ -1146,7 +1433,9 @@ class LargeImageView: NSView {
let rate = Float(sender.tag) / 100.0
globalVar.videoPlaybackRate = rate
UserDefaults.standard.set(rate, forKey: "videoPlaybackRate")
- if let player = queuePlayer, player.rate > 0 {
+ if isUsingMPVPlayer {
+ mpvPlayer?.setRate(rate)
+ } else if let player = queuePlayer, player.rate > 0 {
player.rate = rate
}
}
@@ -1175,8 +1464,19 @@ class LargeImageView: NSView {
func startPeriodicTimeObserver() {
stopPeriodicTimeObserver()
+
+ if isUsingMPVPlayer {
+ periodicTimeObserver = Timer.scheduledTimer(withTimeInterval: 1.0 / 15.0, repeats: true) { [weak self] _ in
+ guard let self = self else { return }
+ let current = CMTime(seconds: self.videoCurrentTimeSeconds, preferredTimescale: 600)
+ let duration = CMTime(seconds: self.videoDurationSeconds, preferredTimescale: 600)
+ guard CMTimeGetSeconds(duration).isFinite else { return }
+ self.videoControlsView.updateProgress(currentTime: current, duration: duration)
+ }
+ return
+ }
- let interval = CMTime(seconds: 1.0 / 120.0, preferredTimescale: 120)
+ let interval = CMTime(seconds: 1.0 / 15.0, preferredTimescale: 600)
periodicTimeObserver = queuePlayer?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
guard let self = self,
let player = self.queuePlayer,
@@ -1190,6 +1490,11 @@ class LargeImageView: NSView {
}
func stopPeriodicTimeObserver() {
+ if let timer = periodicTimeObserver as? Timer {
+ timer.invalidate()
+ periodicTimeObserver = nil
+ return
+ }
if let observer = periodicTimeObserver {
queuePlayer?.removeTimeObserver(observer)
periodicTimeObserver = nil
@@ -1197,13 +1502,218 @@ class LargeImageView: NSView {
}
func showVideoControls() {
- guard file.type == .video, !videoView.isHidden, queuePlayer?.currentItem != nil else { return }
+ guard file.type == .video, ((isUsingMPVPlayer && !mpvVideoView.isHidden) || (!videoView.isHidden && queuePlayer?.currentItem != nil)) else { return }
videoControlsView.showControls()
}
func hideVideoControls() {
videoControlsView.hideControls()
}
+
+ func beginVideoCropSelectionMode() {
+ guard file.type == .video, ((isUsingMPVPlayer && !mpvVideoView.isHidden) || !videoView.isHidden) else { return }
+
+ isSelectingVideoCrop = true
+ videoCropStartPoint = nil
+ videoCropSelectionRect = .zero
+ wasPlayingBeforeVideoCropSelection = videoIsPlaying
+ pauseVideo()
+ videoControlsView.hideControls()
+ ensureVideoCropOverlayLayer()
+ updateVideoCropOverlay(selectionRect: .zero)
+ showInfo(NSLocalizedString("Drag to select video crop area", comment: "拖动选择视频裁剪区域"))
+ }
+
+ private func cancelVideoCropSelectionMode() {
+ guard isSelectingVideoCrop else { return }
+ isSelectingVideoCrop = false
+ videoCropStartPoint = nil
+ videoCropDragMode = nil
+ pendingVideoCropActionButton = nil
+ videoCropSelectionRect = .zero
+ videoCropOverlayView?.removeFromSuperview()
+ videoCropOverlayView = nil
+ if wasPlayingBeforeVideoCropSelection {
+ resumeVideo()
+ }
+ }
+
+ func cancelVideoCropSelection() {
+ cancelVideoCropSelectionMode()
+ }
+
+ func confirmVideoCropSelection() {
+ finishVideoCropSelectionMode()
+ }
+
+ private func finishVideoCropSelectionMode() {
+ guard isSelectingVideoCrop else { return }
+ let selectedRect = videoCropSelectionRect
+ isSelectingVideoCrop = false
+ videoCropStartPoint = nil
+ videoCropDragMode = nil
+ pendingVideoCropActionButton = nil
+ videoCropSelectionRect = .zero
+ videoCropOverlayView?.removeFromSuperview()
+ videoCropOverlayView = nil
+
+ guard let cropRect = makeVideoCropRect(fromSelectionRect: selectedRect) else {
+ showInfo(NSLocalizedString("Crop area is too small", comment: "裁剪区域太小"))
+ if wasPlayingBeforeVideoCropSelection {
+ resumeVideo()
+ }
+ return
+ }
+
+ getViewController(self)?.handleCropCurrentVideo(selection: cropRect)
+ }
+
+ private func ensureVideoCropOverlayLayer() {
+ guard videoCropOverlayView == nil else { return }
+ let overlay = VideoCropOverlayView(frame: bounds)
+ overlay.autoresizingMask = [.width, .height]
+ overlay.wantsLayer = true
+ addSubview(overlay, positioned: .above, relativeTo: videoView)
+ videoCropOverlayView = overlay
+ }
+
+ private func updateVideoCropOverlay(selectionRect: NSRect) {
+ ensureVideoCropOverlayLayer()
+ videoCropOverlayView?.frame = bounds
+ videoCropOverlayView?.selectionRect = selectionRect
+ }
+
+ private func videoContentFrameInSelf() -> NSRect? {
+ let originalSize = file.originalSize ?? file.imageInfo?.size
+ guard let originalSize = originalSize,
+ originalSize.width > 0,
+ originalSize.height > 0 else {
+ return nil
+ }
+ return AVMakeRect(aspectRatio: originalSize, insideRect: videoView.frame)
+ }
+
+ private func makeVideoCropRect(fromSelectionRect selectionRect: NSRect) -> ViewController.VideoCropRect? {
+ guard let contentFrame = videoContentFrameInSelf(),
+ let originalSize = file.originalSize ?? file.imageInfo?.size else {
+ return nil
+ }
+
+ let clipped = selectionRect.intersection(contentFrame)
+ guard clipped.width >= 4, clipped.height >= 4 else { return nil }
+
+ var x = Int(((clipped.minX - contentFrame.minX) / contentFrame.width * originalSize.width).rounded(.down))
+ var y = Int(((contentFrame.maxY - clipped.maxY) / contentFrame.height * originalSize.height).rounded(.down))
+ var width = Int((clipped.width / contentFrame.width * originalSize.width).rounded(.down))
+ var height = Int((clipped.height / contentFrame.height * originalSize.height).rounded(.down))
+
+ x = max(0, min(x, Int(originalSize.width) - 2))
+ y = max(0, min(y, Int(originalSize.height) - 2))
+ width = max(2, min(width, Int(originalSize.width) - x))
+ height = max(2, min(height, Int(originalSize.height) - y))
+
+ x -= x % 2
+ y -= y % 2
+ width -= width % 2
+ height -= height % 2
+
+ guard width >= 2, height >= 2 else { return nil }
+ return ViewController.VideoCropRect(x: x, y: y, width: width, height: height)
+ }
+
+ private func videoCropDragMode(at point: NSPoint) -> VideoCropDragMode {
+ let rect = videoCropSelectionRect
+ guard !rect.isEmpty else { return .new }
+
+ let tolerance: CGFloat = 12
+ let nearLeft = abs(point.x - rect.minX) <= tolerance
+ let nearRight = abs(point.x - rect.maxX) <= tolerance
+ let nearBottom = abs(point.y - rect.minY) <= tolerance
+ let nearTop = abs(point.y - rect.maxY) <= tolerance
+ let expanded = rect.insetBy(dx: -tolerance, dy: -tolerance)
+
+ guard expanded.contains(point) else { return .new }
+
+ if nearLeft && nearTop { return .resizeTopLeft }
+ if nearRight && nearTop { return .resizeTopRight }
+ if nearLeft && nearBottom { return .resizeBottomLeft }
+ if nearRight && nearBottom { return .resizeBottomRight }
+ if nearLeft { return .resizeLeft }
+ if nearRight { return .resizeRight }
+ if nearTop { return .resizeTop }
+ if nearBottom { return .resizeBottom }
+ if rect.contains(point) { return .move }
+ return .new
+ }
+
+ private func adjustedVideoCropRect(to point: NSPoint, in contentFrame: NSRect) -> NSRect {
+ guard let startPoint = videoCropStartPoint,
+ let mode = videoCropDragMode else {
+ return .zero
+ }
+
+ let clampedPoint = NSPoint(
+ x: min(max(point.x, contentFrame.minX), contentFrame.maxX),
+ y: min(max(point.y, contentFrame.minY), contentFrame.maxY)
+ )
+ let minSize: CGFloat = 4
+ var rect = videoCropDragOriginalRect
+
+ switch mode {
+ case .new:
+ rect = NSRect(
+ x: min(startPoint.x, clampedPoint.x),
+ y: min(startPoint.y, clampedPoint.y),
+ width: abs(clampedPoint.x - startPoint.x),
+ height: abs(clampedPoint.y - startPoint.y)
+ )
+ case .move:
+ let dx = clampedPoint.x - startPoint.x
+ let dy = clampedPoint.y - startPoint.y
+ rect.origin.x = min(max(videoCropDragOriginalRect.origin.x + dx, contentFrame.minX), contentFrame.maxX - rect.width)
+ rect.origin.y = min(max(videoCropDragOriginalRect.origin.y + dy, contentFrame.minY), contentFrame.maxY - rect.height)
+ case .resizeLeft, .resizeTopLeft, .resizeBottomLeft:
+ rect.origin.x = min(clampedPoint.x, videoCropDragOriginalRect.maxX - minSize)
+ rect.size.width = videoCropDragOriginalRect.maxX - rect.origin.x
+ if mode == .resizeTopLeft {
+ rect.size.height = max(minSize, min(clampedPoint.y, contentFrame.maxY) - videoCropDragOriginalRect.minY)
+ } else if mode == .resizeBottomLeft {
+ rect.origin.y = min(clampedPoint.y, videoCropDragOriginalRect.maxY - minSize)
+ rect.size.height = videoCropDragOriginalRect.maxY - rect.origin.y
+ }
+ case .resizeRight, .resizeTopRight, .resizeBottomRight:
+ rect.size.width = max(minSize, clampedPoint.x - videoCropDragOriginalRect.minX)
+ if mode == .resizeTopRight {
+ rect.size.height = max(minSize, min(clampedPoint.y, contentFrame.maxY) - videoCropDragOriginalRect.minY)
+ } else if mode == .resizeBottomRight {
+ rect.origin.y = min(clampedPoint.y, videoCropDragOriginalRect.maxY - minSize)
+ rect.size.height = videoCropDragOriginalRect.maxY - rect.origin.y
+ }
+ case .resizeTop:
+ rect.size.height = max(minSize, clampedPoint.y - videoCropDragOriginalRect.minY)
+ case .resizeBottom:
+ rect.origin.y = min(clampedPoint.y, videoCropDragOriginalRect.maxY - minSize)
+ rect.size.height = videoCropDragOriginalRect.maxY - rect.origin.y
+ }
+
+ rect.origin.x = max(contentFrame.minX, min(rect.origin.x, contentFrame.maxX - minSize))
+ rect.origin.y = max(contentFrame.minY, min(rect.origin.y, contentFrame.maxY - minSize))
+ rect.size.width = max(minSize, min(rect.width, contentFrame.maxX - rect.origin.x))
+ rect.size.height = max(minSize, min(rect.height, contentFrame.maxY - rect.origin.y))
+ return rect
+ }
+
+ private func videoCropActionButton(at point: NSPoint) -> VideoCropActionButton? {
+ guard let overlay = videoCropOverlayView,
+ !videoCropSelectionRect.isEmpty else { return nil }
+ if overlay.confirmButtonRect().contains(point) {
+ return .confirm
+ }
+ if overlay.cancelButtonRect().contains(point) {
+ return .cancel
+ }
+ return nil
+ }
func enableBlackBg() {
if let effectView = getViewController(self)?.largeImageBgEffectView,
@@ -1449,7 +1959,11 @@ class LargeImageView: NSView {
@objc func actOpenWithExternalPlayer() {
guard let url = URL(string: file.path) else { return }
- NSWorkspace.shared.open(url)
+ if file.type == .video {
+ openVideoWithPreferredExternalPlayer(url)
+ } else {
+ NSWorkspace.shared.open(url)
+ }
}
func getCurrentImageOriginalSizeInScreenScale() -> NSSize? {
@@ -1489,7 +2003,7 @@ class LargeImageView: NSView {
override func mouseMoved(with event: NSEvent) {
super.mouseMoved(with: event)
- if file.type == .video && !videoView.isHidden {
+ if file.type == .video && ((isUsingMPVPlayer && !mpvVideoView.isHidden) || !videoView.isHidden) {
showVideoControls()
}
@@ -1524,6 +2038,27 @@ class LargeImageView: NSView {
}
override func mouseDown(with event: NSEvent) {
+ if isSelectingVideoCrop {
+ guard !isEventInVideoControls(event) else { return }
+ let location = self.convert(event.locationInWindow, from: nil)
+ if let actionButton = videoCropActionButton(at: location) {
+ pendingVideoCropActionButton = actionButton
+ return
+ }
+ guard let contentFrame = videoContentFrameInSelf(),
+ contentFrame.contains(location) else { return }
+ pendingVideoCropActionButton = nil
+ videoCropStartPoint = location
+ videoCropDragMode = videoCropDragMode(at: location)
+ videoCropDragOriginalRect = videoCropSelectionRect
+ if videoCropDragMode == .new {
+ videoCropSelectionRect = .zero
+ videoCropDragOriginalRect = .zero
+ updateVideoCropOverlay(selectionRect: .zero)
+ }
+ return
+ }
+
if isEventInVideoControls(event) { return }
// 临时按住左键也能缩放
@@ -1596,6 +2131,36 @@ class LargeImageView: NSView {
}
override func mouseUp(with event: NSEvent) {
+ if isSelectingVideoCrop {
+ guard !isEventInVideoControls(event) else { return }
+ let location = self.convert(event.locationInWindow, from: nil)
+ if let pending = pendingVideoCropActionButton {
+ pendingVideoCropActionButton = nil
+ if videoCropActionButton(at: location) == pending {
+ switch pending {
+ case .confirm:
+ finishVideoCropSelectionMode()
+ case .cancel:
+ cancelVideoCropSelectionMode()
+ }
+ }
+ return
+ }
+ if videoCropStartPoint == nil {
+ return
+ }
+ videoCropStartPoint = nil
+ if makeVideoCropRect(fromSelectionRect: videoCropSelectionRect) != nil {
+ showInfo(NSLocalizedString("Use the check button to crop, drag again to adjust", comment: "点击对号裁剪,重新拖动可调整"))
+ } else {
+ videoCropSelectionRect = .zero
+ updateVideoCropOverlay(selectionRect: .zero)
+ showInfo(NSLocalizedString("Crop area is too small", comment: "裁剪区域太小"))
+ }
+ videoCropDragMode = nil
+ return
+ }
+
if isEventInVideoControls(event) { return }
if !(getViewController(self)!.publicVar.isRightMouseDown) {
@@ -1672,6 +2237,17 @@ class LargeImageView: NSView {
}
override func mouseDragged(with event: NSEvent) {
+ if isSelectingVideoCrop {
+ pendingVideoCropActionButton = nil
+ guard videoCropStartPoint != nil,
+ let contentFrame = videoContentFrameInSelf() else { return }
+ let currentPoint = self.convert(event.locationInWindow, from: nil)
+ let rect = adjustedVideoCropRect(to: currentPoint, in: contentFrame)
+ videoCropSelectionRect = rect
+ updateVideoCropOverlay(selectionRect: rect)
+ return
+ }
+
if isEventInVideoControls(event) { return }
guard let lastLocation = lastDragLocation else { return }
if isInOcrState && !getViewController(self)!.publicVar.isRightMouseDown {return}
@@ -1766,12 +2342,17 @@ class LargeImageView: NSView {
}
override func rightMouseDown(with event: NSEvent) {
+ if isSelectingVideoCrop {
+ cancelVideoCropSelectionMode()
+ return
+ }
getViewController(self)!.publicVar.isRightMouseDown = true
mouseDown(with: event)
// super.rightMouseDown(with: event) // 继续传递事件
}
override func rightMouseUp(with event: NSEvent) {
+ if isSelectingVideoCrop { return }
mouseUp(with: event)
getViewController(self)!.publicVar.isRightMouseDown = false
@@ -1874,6 +2455,8 @@ class LargeImageView: NSView {
let playbackRateItem = menu.addItem(withTitle: NSLocalizedString("Playback Speed", comment: "播放速度"), action: nil, keyEquivalent: "")
playbackRateItem.submenu = buildPlaybackRateSubmenu()
+
+ menu.addItem(withTitle: NSLocalizedString("Crop Video Size...", comment: "裁剪视频尺寸..."), action: #selector(actCropVideoSize), keyEquivalent: "")
}
menu.addItem(NSMenuItem.separator())
@@ -2238,6 +2821,10 @@ class LargeImageView: NSView {
doRotateL()
}
}
+
+ @objc func actCropVideoSize() {
+ getViewController(self)?.handleBatchCropSelectedVideos()
+ }
func doRotateR() {
file.rotate = (file.rotate+1)%4
diff --git a/FlowVision/Sources/Views/MPVPlayerBackend.swift b/FlowVision/Sources/Views/MPVPlayerBackend.swift
new file mode 100644
index 00000000..de1ccb89
--- /dev/null
+++ b/FlowVision/Sources/Views/MPVPlayerBackend.swift
@@ -0,0 +1,741 @@
+//
+// MPVPlayerBackend.swift
+// FlowVision
+//
+
+import Cocoa
+import Darwin
+import OpenGL.GL
+import OpenGL.GL3
+
+private let mpvFormatFlag: Int32 = 3
+private let mpvFormatDouble: Int32 = 5
+
+private let mpvRenderParamAPIType: Int32 = 1
+private let mpvRenderParamOpenGLInitParams: Int32 = 2
+private let mpvRenderParamOpenGLFBO: Int32 = 3
+private let mpvRenderParamFlipY: Int32 = 4
+private let mpvRenderParamDepth: Int32 = 5
+private let mpvRenderParamAdvancedControl: Int32 = 10
+private let mpvRenderUpdateFrame: UInt64 = 1
+
+private struct MPVOpenGLInitParams {
+ var getProcAddress: (@convention(c) (UnsafeMutableRawPointer?, UnsafePointer?) -> UnsafeMutableRawPointer?)?
+ var getProcAddressCtx: UnsafeMutableRawPointer?
+}
+
+private struct MPVOpenGLFBO {
+ var fbo: Int32
+ var w: Int32
+ var h: Int32
+ var internalFormat: Int32
+}
+
+private struct MPVRenderParam {
+ var type: Int32 = 0
+ var data: UnsafeMutableRawPointer?
+}
+
+private final class LibMPV {
+ typealias MPVCreate = @convention(c) () -> OpaquePointer?
+ typealias MPVInitialize = @convention(c) (OpaquePointer?) -> Int32
+ typealias MPVTerminateDestroy = @convention(c) (OpaquePointer?) -> Void
+ typealias MPVCommand = @convention(c) (OpaquePointer?, UnsafeMutablePointer?>?) -> Int32
+ typealias MPVSetOptionString = @convention(c) (OpaquePointer?, UnsafePointer?, UnsafePointer?) -> Int32
+ typealias MPVSetProperty = @convention(c) (OpaquePointer?, UnsafePointer?, Int32, UnsafeMutableRawPointer?) -> Int32
+ typealias MPVGetProperty = @convention(c) (OpaquePointer?, UnsafePointer?, Int32, UnsafeMutableRawPointer?) -> Int32
+ typealias MPVRenderContextCreate = @convention(c) (UnsafeMutablePointer?, OpaquePointer?, UnsafeMutableRawPointer?) -> Int32
+ typealias MPVRenderContextSetUpdateCallback = @convention(c) (OpaquePointer?, (@convention(c) (UnsafeMutableRawPointer?) -> Void)?, UnsafeMutableRawPointer?) -> Void
+ typealias MPVRenderContextUpdate = @convention(c) (OpaquePointer?) -> UInt64
+ typealias MPVRenderContextRender = @convention(c) (OpaquePointer?, UnsafeMutableRawPointer?) -> Int32
+ typealias MPVRenderContextReportSwap = @convention(c) (OpaquePointer?) -> Void
+ typealias MPVRenderContextFree = @convention(c) (OpaquePointer?) -> Void
+
+ let create: MPVCreate
+ let initialize: MPVInitialize
+ let terminateDestroy: MPVTerminateDestroy
+ let command: MPVCommand
+ let setOptionString: MPVSetOptionString
+ let setProperty: MPVSetProperty
+ let getProperty: MPVGetProperty
+ let renderContextCreate: MPVRenderContextCreate
+ let renderContextSetUpdateCallback: MPVRenderContextSetUpdateCallback
+ let renderContextUpdate: MPVRenderContextUpdate
+ let renderContextRender: MPVRenderContextRender
+ let renderContextReportSwap: MPVRenderContextReportSwap
+ let renderContextFree: MPVRenderContextFree
+
+ private let handle: UnsafeMutableRawPointer
+
+ static let shared: LibMPV? = LibMPV()
+
+ private init?() {
+ guard let loadedHandle = Self.openLibrary() else {
+ log("libmpv not found. Bundle IINA's mpv runtime in Contents/Frameworks to enable mpv playback.", level: .warn)
+ return nil
+ }
+ handle = loadedHandle
+
+ guard
+ let create: MPVCreate = Self.load("mpv_create", from: loadedHandle),
+ let initialize: MPVInitialize = Self.load("mpv_initialize", from: loadedHandle),
+ let terminateDestroy: MPVTerminateDestroy = Self.load("mpv_terminate_destroy", from: loadedHandle),
+ let command: MPVCommand = Self.load("mpv_command", from: loadedHandle),
+ let setOptionString: MPVSetOptionString = Self.load("mpv_set_option_string", from: loadedHandle),
+ let setProperty: MPVSetProperty = Self.load("mpv_set_property", from: loadedHandle),
+ let getProperty: MPVGetProperty = Self.load("mpv_get_property", from: loadedHandle),
+ let renderContextCreate: MPVRenderContextCreate = Self.load("mpv_render_context_create", from: loadedHandle),
+ let renderContextSetUpdateCallback: MPVRenderContextSetUpdateCallback = Self.load("mpv_render_context_set_update_callback", from: loadedHandle),
+ let renderContextUpdate: MPVRenderContextUpdate = Self.load("mpv_render_context_update", from: loadedHandle),
+ let renderContextRender: MPVRenderContextRender = Self.load("mpv_render_context_render", from: loadedHandle),
+ let renderContextReportSwap: MPVRenderContextReportSwap = Self.load("mpv_render_context_report_swap", from: loadedHandle),
+ let renderContextFree: MPVRenderContextFree = Self.load("mpv_render_context_free", from: loadedHandle)
+ else {
+ dlclose(handle)
+ log("libmpv is present but render API symbols are missing.", level: .error)
+ return nil
+ }
+
+ self.create = create
+ self.initialize = initialize
+ self.terminateDestroy = terminateDestroy
+ self.command = command
+ self.setOptionString = setOptionString
+ self.setProperty = setProperty
+ self.getProperty = getProperty
+ self.renderContextCreate = renderContextCreate
+ self.renderContextSetUpdateCallback = renderContextSetUpdateCallback
+ self.renderContextUpdate = renderContextUpdate
+ self.renderContextRender = renderContextRender
+ self.renderContextReportSwap = renderContextReportSwap
+ self.renderContextFree = renderContextFree
+ }
+
+ deinit {
+ dlclose(handle)
+ }
+
+ private static func load(_ symbol: String, from handle: UnsafeMutableRawPointer) -> T? {
+ guard let pointer = dlsym(handle, symbol) else { return nil }
+ return unsafeBitCast(pointer, to: T.self)
+ }
+
+ private static func openLibrary() -> UnsafeMutableRawPointer? {
+ let frameworkDirs = [
+ Bundle.main.privateFrameworksPath,
+ "/Applications/IINA.app/Contents/Frameworks"
+ ].compactMap { $0 }
+
+ for dir in frameworkDirs {
+ if let handle = openLibrary(in: dir) {
+ return handle
+ }
+ }
+
+ for path in [
+ "@rpath/libmpv.2.dylib",
+ "@rpath/libmpv.dylib",
+ "/opt/homebrew/lib/libmpv.2.dylib",
+ "/opt/homebrew/lib/libmpv.dylib",
+ "/usr/local/lib/libmpv.2.dylib",
+ "/usr/local/lib/libmpv.dylib",
+ "libmpv.2.dylib",
+ "libmpv.dylib"
+ ] {
+ if let handle = dlopen(path, RTLD_NOW | RTLD_LOCAL) {
+ return handle
+ }
+ }
+ return nil
+ }
+
+ private static func openLibrary(in dir: String) -> UnsafeMutableRawPointer? {
+ let libmpv = URL(fileURLWithPath: dir).appendingPathComponent("libmpv.2.dylib").path
+ guard FileManager.default.fileExists(atPath: libmpv) else { return nil }
+
+ let dylibs = ((try? FileManager.default.contentsOfDirectory(atPath: dir)) ?? [])
+ .filter { $0.hasSuffix(".dylib") && $0 != "libmpv.2.dylib" }
+
+ for _ in 0..<4 {
+ for name in dylibs {
+ _ = dlopen(URL(fileURLWithPath: dir).appendingPathComponent(name).path, RTLD_NOW | RTLD_GLOBAL)
+ }
+ }
+ return dlopen(libmpv, RTLD_NOW | RTLD_LOCAL)
+ }
+}
+
+final class FlowMPVVideoView: NSView {
+ fileprivate lazy var videoLayer = MPVRenderLayer()
+ fileprivate weak var backend: MPVPlayerBackend?
+ private var displayLink: CVDisplayLink?
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+ wantsLayer = true
+ layer = videoLayer
+ videoLayer.owner = self
+ autoresizingMask = [.width, .height]
+ wantsBestResolutionOpenGLSurface = true
+ wantsExtendedDynamicRangeOpenGLSurface = true
+ }
+
+ required init?(coder: NSCoder) {
+ super.init(coder: coder)
+ wantsLayer = true
+ layer = videoLayer
+ videoLayer.owner = self
+ }
+
+ override var isOpaque: Bool { true }
+
+ func attach(_ backend: MPVPlayerBackend) {
+ self.backend = backend
+ videoLayer.backend = backend
+ backend.initializeRendering(with: videoLayer)
+ startDisplayLink()
+ videoLayer.update(force: true)
+ }
+
+ func detach() {
+ stopDisplayLink()
+ backend = nil
+ videoLayer.backend = nil
+ }
+
+ private func startDisplayLink() {
+ if displayLink == nil {
+ CVDisplayLinkCreateWithActiveCGDisplays(&displayLink)
+ }
+ guard let displayLink, !CVDisplayLinkIsRunning(displayLink) else { return }
+ CVDisplayLinkSetOutputCallback(displayLink, flowMPVDisplayLinkCallback, Unmanaged.passUnretained(self).toOpaque())
+ CVDisplayLinkStart(displayLink)
+ }
+
+ private func stopDisplayLink() {
+ if let displayLink, CVDisplayLinkIsRunning(displayLink) {
+ CVDisplayLinkStop(displayLink)
+ }
+ }
+
+ fileprivate func reportSwap() {
+ backend?.reportSwap()
+ }
+}
+
+private final class MPVRenderLayer: CAOpenGLLayer {
+ weak var owner: FlowMPVVideoView?
+ weak var backend: MPVPlayerBackend?
+
+ private let cglPixelFormat: CGLPixelFormatObj
+ fileprivate let cglContext: CGLContextObj
+ private let displayLock = NSRecursiveLock()
+ private let renderQueue = DispatchQueue(label: "netdcy.FlowVision.mpv.render", qos: .userInteractive)
+ private var needsFlip = false
+ private var forceDraw = true
+ private var fbo: GLint = 1
+ private var bufferDepth: GLint = 8
+
+ override init() {
+ cglPixelFormat = MPVRenderLayer.createPixelFormat()
+ cglContext = MPVRenderLayer.createContext(cglPixelFormat)
+ super.init()
+ autoresizingMask = [.layerWidthSizable, .layerHeightSizable]
+ backgroundColor = NSColor.black.cgColor
+ isAsynchronous = false
+ }
+
+ override init(layer: Any) {
+ let previous = layer as! MPVRenderLayer
+ cglPixelFormat = previous.cglPixelFormat
+ cglContext = previous.cglContext
+ backend = previous.backend
+ owner = previous.owner
+ super.init(layer: layer)
+ autoresizingMask = previous.autoresizingMask
+ backgroundColor = previous.backgroundColor
+ }
+
+ required init?(coder: NSCoder) {
+ cglPixelFormat = MPVRenderLayer.createPixelFormat()
+ cglContext = MPVRenderLayer.createContext(cglPixelFormat)
+ super.init(coder: coder)
+ }
+
+ override func canDraw(inCGLContext ctx: CGLContextObj, pixelFormat pf: CGLPixelFormatObj, forLayerTime t: CFTimeInterval, displayTime ts: UnsafePointer?) -> Bool {
+ forceDraw || backend?.shouldRenderUpdateFrame() == true
+ }
+
+ override func draw(inCGLContext ctx: CGLContextObj, pixelFormat pf: CGLPixelFormatObj, forLayerTime t: CFTimeInterval, displayTime ts: UnsafePointer?) {
+ needsFlip = false
+ forceDraw = false
+
+ glClear(GLbitfield(GL_COLOR_BUFFER_BIT))
+
+ guard let backend else {
+ glClearColor(0, 0, 0, 1)
+ glClear(GLbitfield(GL_COLOR_BUFFER_BIT))
+ glFlush()
+ return
+ }
+
+ var currentFBO: GLint = 0
+ glGetIntegerv(GLenum(GL_DRAW_FRAMEBUFFER_BINDING), ¤tFBO)
+ if currentFBO != 0 { fbo = currentFBO }
+
+ var viewport: [GLint] = [0, 0, 0, 0]
+ glGetIntegerv(GLenum(GL_VIEWPORT), &viewport)
+
+ var flip: CInt = 1
+ var fboData = MPVOpenGLFBO(
+ fbo: Int32(fbo),
+ w: Int32(viewport[2]),
+ h: Int32(viewport[3]),
+ internalFormat: 0
+ )
+ var depth = bufferDepth
+
+ withUnsafeMutablePointer(to: &fboData) { fboPointer in
+ withUnsafeMutablePointer(to: &flip) { flipPointer in
+ withUnsafeMutablePointer(to: &depth) { depthPointer in
+ var params = [
+ MPVRenderParam(type: mpvRenderParamOpenGLFBO, data: UnsafeMutableRawPointer(fboPointer)),
+ MPVRenderParam(type: mpvRenderParamFlipY, data: UnsafeMutableRawPointer(flipPointer)),
+ MPVRenderParam(type: mpvRenderParamDepth, data: UnsafeMutableRawPointer(depthPointer)),
+ MPVRenderParam()
+ ]
+ backend.render(params: ¶ms)
+ }
+ }
+ }
+ glFlush()
+ }
+
+ override func copyCGLPixelFormat(forDisplayMask mask: UInt32) -> CGLPixelFormatObj {
+ cglPixelFormat
+ }
+
+ override func copyCGLContext(forPixelFormat pf: CGLPixelFormatObj) -> CGLContextObj {
+ cglContext
+ }
+
+ func update(force: Bool = false) {
+ renderQueue.async { [weak self] in
+ guard let self else { return }
+ if force { self.forceDraw = true }
+ self.needsFlip = true
+ self.displayLock.lock()
+ CATransaction.begin()
+ self.display()
+ CATransaction.commit()
+ CATransaction.flush()
+ self.displayLock.unlock()
+ }
+ }
+
+ private static func createPixelFormat() -> CGLPixelFormatObj {
+ let attrs: [CGLPixelFormatAttribute] = [
+ kCGLPFAOpenGLProfile, CGLPixelFormatAttribute(kCGLOGLPVersion_3_2_Core.rawValue),
+ kCGLPFAAccelerated,
+ kCGLPFADoubleBuffer,
+ kCGLPFAAllowOfflineRenderers,
+ kCGLPFASupportsAutomaticGraphicsSwitching,
+ _CGLPixelFormatAttribute(rawValue: 0)
+ ]
+ var pixelFormat: CGLPixelFormatObj?
+ var pixelCount: GLint = 0
+ CGLChoosePixelFormat(attrs, &pixelFormat, &pixelCount)
+ if let pixelFormat { return pixelFormat }
+
+ let fallback: [CGLPixelFormatAttribute] = [
+ kCGLPFAOpenGLProfile, CGLPixelFormatAttribute(kCGLOGLPVersion_Legacy.rawValue),
+ kCGLPFAAccelerated,
+ kCGLPFADoubleBuffer,
+ _CGLPixelFormatAttribute(rawValue: 0)
+ ]
+ CGLChoosePixelFormat(fallback, &pixelFormat, &pixelCount)
+ return pixelFormat!
+ }
+
+ private static func createContext(_ pixelFormat: CGLPixelFormatObj) -> CGLContextObj {
+ var context: CGLContextObj?
+ CGLCreateContext(pixelFormat, nil, &context)
+ var swapInterval: GLint = 1
+ CGLSetParameter(context!, kCGLCPSwapInterval, &swapInterval)
+ CGLEnable(context!, kCGLCEMPEngine)
+ return context!
+ }
+}
+
+final class MPVPlayerBackend {
+ private static let subtitleExtensions = ["srt", "ass", "ssa", "vtt", "sub", "idx", "smi", "sami"]
+
+ private let lib: LibMPV
+ private var handle: OpaquePointer?
+ private var renderContext: OpaquePointer?
+ private weak var renderView: FlowMPVVideoView?
+ private weak var renderLayer: MPVRenderLayer?
+ private let renderContextLock = NSRecursiveLock()
+ private var progressTimer: Timer?
+ private var endHandler: (() -> Void)?
+ private var abRange: ClosedRange?
+ private var isStopping = false
+
+ var isActive: Bool { handle != nil }
+ var isPlaying: Bool { !getFlag("pause") && isActive }
+ var currentTime: Double { getDouble("time-pos") }
+ var duration: Double { getDouble("duration") }
+
+ var volume: Float {
+ get { Float(max(0, min(100, getDouble("volume"))) / 100.0) }
+ set {
+ let mpvVolume = Double(max(0, min(1, newValue)) * 100)
+ setDouble("volume", mpvVolume)
+ }
+ }
+
+ init?(renderView: FlowMPVVideoView) {
+ guard let lib = LibMPV.shared else { return nil }
+ self.lib = lib
+ self.renderView = renderView
+ }
+
+ deinit {
+ stop()
+ }
+
+ func load(url: URL, startTime: Double?, volume: Float, rate: Float, rotation: Int, abRange: ClosedRange?, loop: Bool, endHandler: (() -> Void)?) -> Bool {
+ stop(destroyHandle: true)
+
+ guard let mpv = lib.create() else {
+ log("mpv_create failed.", level: .error)
+ return false
+ }
+ handle = mpv
+ self.endHandler = endHandler
+ self.abRange = abRange
+
+ setOption("terminal", "no")
+ setOption("msg-level", "all=warn")
+ setOption("osc", "no")
+ setOption("input-default-bindings", "no")
+ setOption("input-vo-keyboard", "no")
+ setOption("vo", "libmpv")
+ setOption("hwdec", "auto-safe")
+ setOption("gpu-api", "opengl")
+ setOption("gpu-hwdec-interop", "auto")
+ setOption("vd-lavc-dr", "yes")
+ setOption("video-sync", "display-resample")
+ setOption("interpolation", "yes")
+ setOption("opengl-swapinterval", "1")
+ setOption("force-window", "no")
+ setOption("keep-open", "yes")
+ setOption("sub-auto", "no")
+ // Mounted SMB paths look like local files to mpv, so force a bounded
+ // read-ahead cache instead of relying on small on-demand reads.
+ setOption("cache", "yes")
+ setOption("cache-on-disk", "yes")
+ setOption("cache-secs", "5")
+ setOption("demuxer-readahead-secs", "20")
+ setOption("demuxer-max-bytes", "134217728")
+ setOption("demuxer-max-back-bytes", "33554432")
+ setOption("volume", "\(Int(max(0, min(1, volume)) * 100))")
+ setOption("speed", "\(rate)")
+ if rotation != 0 {
+ setOption("video-rotate", "\(rotation * 90)")
+ }
+
+ guard lib.initialize(mpv) >= 0 else {
+ log("mpv_initialize failed; falling back to AVPlayer.", level: .error)
+ stop(destroyHandle: true)
+ return false
+ }
+
+ renderView?.attach(self)
+ guard renderContext != nil else {
+ log("mpv render context failed; falling back to AVPlayer.", level: .error)
+ stop(destroyHandle: true)
+ return false
+ }
+
+ var args: [String?] = ["loadfile", url.path, "replace"]
+ if let startTime, startTime > 0 {
+ args.append("start=\(startTime)")
+ } else if let abStart = abRange?.lowerBound {
+ args.append("start=\(abStart)")
+ }
+ guard command(args) >= 0 else {
+ stop(destroyHandle: true)
+ return false
+ }
+ loadExternalSubtitles(for: url)
+ setPaused(false)
+ startProgressTimer(loop: loop)
+ return true
+ }
+
+ fileprivate func initializeRendering(with layer: MPVRenderLayer) {
+ guard renderContext == nil, let handle else { return }
+ renderLayer = layer
+ CGLLockContext(layer.cglContext)
+ CGLSetCurrentContext(layer.cglContext)
+ defer { CGLUnlockContext(layer.cglContext) }
+
+ renderContextLock.lock()
+ defer { renderContextLock.unlock() }
+
+ var initParams = MPVOpenGLInitParams(getProcAddress: flowMPVGetOpenGLProcAddress, getProcAddressCtx: nil)
+ var advanced: CInt = 1
+ "opengl".withCString { api in
+ withUnsafeMutablePointer(to: &initParams) { initPointer in
+ withUnsafeMutablePointer(to: &advanced) { advancedPointer in
+ var params = [
+ MPVRenderParam(type: mpvRenderParamAPIType, data: UnsafeMutableRawPointer(mutating: api)),
+ MPVRenderParam(type: mpvRenderParamOpenGLInitParams, data: UnsafeMutableRawPointer(initPointer)),
+ MPVRenderParam(type: mpvRenderParamAdvancedControl, data: UnsafeMutableRawPointer(advancedPointer)),
+ MPVRenderParam()
+ ]
+ var context: OpaquePointer?
+ let result = params.withUnsafeMutableBufferPointer { buffer in
+ lib.renderContextCreate(&context, handle, UnsafeMutableRawPointer(buffer.baseAddress))
+ }
+ if result >= 0 {
+ renderContext = context
+ lib.renderContextSetUpdateCallback(context, flowMPVRenderUpdateCallback, Unmanaged.passUnretained(layer).toOpaque())
+ }
+ }
+ }
+ }
+ }
+
+ func stop(destroyHandle: Bool = true) {
+ progressTimer?.invalidate()
+ progressTimer = nil
+ abRange = nil
+ endHandler = nil
+ isStopping = true
+ renderView?.detach()
+
+ do {
+ renderContextLock.lock()
+ defer { renderContextLock.unlock() }
+
+ if let renderContext {
+ lib.renderContextSetUpdateCallback(renderContext, nil, nil)
+ lib.renderContextFree(renderContext)
+ self.renderContext = nil
+ }
+ renderLayer = nil
+ }
+
+ guard let handle else {
+ isStopping = false
+ return
+ }
+ _ = command(["stop"])
+ if destroyHandle {
+ lib.terminateDestroy(handle)
+ self.handle = nil
+ }
+ isStopping = false
+ }
+
+ func setPaused(_ paused: Bool) {
+ var value: Int32 = paused ? 1 : 0
+ setProperty("pause", format: mpvFormatFlag, value: &value)
+ }
+
+ func setRate(_ rate: Float) {
+ setDouble("speed", Double(rate))
+ }
+
+ func seek(to seconds: Double) {
+ _ = command(["seek", "\(boundedTime(seconds))", "absolute", "exact"])
+ }
+
+ func reportSwap() {
+ renderContextLock.lock()
+ defer { renderContextLock.unlock() }
+
+ guard let renderContext else { return }
+ lib.renderContextReportSwap(renderContext)
+ }
+
+ func shouldRenderUpdateFrame() -> Bool {
+ renderContextLock.lock()
+ defer { renderContextLock.unlock() }
+
+ guard let renderContext else { return false }
+ return (lib.renderContextUpdate(renderContext) & mpvRenderUpdateFrame) != 0
+ }
+
+ fileprivate func render(params: inout [MPVRenderParam]) {
+ renderContextLock.lock()
+ defer { renderContextLock.unlock() }
+
+ guard let renderContext, let layer = renderLayer else { return }
+ CGLLockContext(layer.cglContext)
+ defer { CGLUnlockContext(layer.cglContext) }
+
+ CGLSetCurrentContext(layer.cglContext)
+ _ = params.withUnsafeMutableBufferPointer { buffer in
+ lib.renderContextRender(renderContext, UnsafeMutableRawPointer(buffer.baseAddress))
+ }
+ }
+
+ private func startProgressTimer(loop: Bool) {
+ progressTimer?.invalidate()
+ progressTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 15.0, repeats: true) { [weak self] _ in
+ guard let self, self.isActive, !self.isStopping else { return }
+ if let range = self.abRange, self.currentTime >= range.upperBound {
+ if loop {
+ self.seek(to: range.lowerBound)
+ self.setPaused(false)
+ } else {
+ self.setPaused(true)
+ self.endHandler?()
+ }
+ return
+ }
+ if self.getFlag("eof-reached") {
+ if loop {
+ self.seek(to: self.abRange?.lowerBound ?? 0)
+ self.setPaused(false)
+ } else {
+ self.endHandler?()
+ }
+ }
+ }
+ }
+
+ private func boundedTime(_ seconds: Double) -> Double {
+ var target = seconds
+ if let range = abRange {
+ target = max(range.lowerBound, min(range.upperBound, target))
+ } else {
+ let total = duration
+ target = total.isFinite && total > 0 ? max(0, min(total, target)) : max(0, target)
+ }
+ return target
+ }
+
+ private func loadExternalSubtitles(for videoURL: URL) {
+ let subtitles = Self.matchingSubtitleURLs(for: videoURL)
+ for (index, subtitle) in subtitles.enumerated() {
+ _ = command(["sub-add", subtitle.path, index == 0 ? "select" : "auto"])
+ }
+ }
+
+ private static func matchingSubtitleURLs(for videoURL: URL) -> [URL] {
+ let directory = videoURL.deletingLastPathComponent()
+ let videoBaseName = videoURL.deletingPathExtension().lastPathComponent.lowercased()
+ guard !videoBaseName.isEmpty,
+ let contents = try? FileManager.default.contentsOfDirectory(
+ at: directory,
+ includingPropertiesForKeys: nil,
+ options: [.skipsHiddenFiles]
+ )
+ else {
+ return []
+ }
+
+ let subtitleExtensionRank = Dictionary(uniqueKeysWithValues: subtitleExtensions.enumerated().map { ($0.element, $0.offset) })
+ let candidates = contents.compactMap { fileURL -> (url: URL, matchRank: Int, extensionRank: Int)? in
+ let ext = fileURL.pathExtension.lowercased()
+ guard let extensionRank = subtitleExtensionRank[ext] else { return nil }
+
+ let baseName = fileURL.deletingPathExtension().lastPathComponent.lowercased()
+ let matchRank: Int
+ if baseName == videoBaseName {
+ matchRank = 0
+ } else if baseName.hasPrefix(videoBaseName + ".") || baseName.hasPrefix(videoBaseName + " ") {
+ matchRank = 1
+ } else {
+ return nil
+ }
+
+ return (fileURL, matchRank, extensionRank)
+ }
+
+ return candidates
+ .sorted {
+ if $0.matchRank != $1.matchRank { return $0.matchRank < $1.matchRank }
+ if $0.extensionRank != $1.extensionRank { return $0.extensionRank < $1.extensionRank }
+ return $0.url.lastPathComponent.localizedStandardCompare($1.url.lastPathComponent) == .orderedAscending
+ }
+ .map(\.url)
+ }
+
+ private func setOption(_ name: String, _ value: String) {
+ guard let handle else { return }
+ _ = lib.setOptionString(handle, name, value)
+ }
+
+ private func command(_ args: [String?]) -> Int32 {
+ guard let handle else { return -1 }
+ let mutableArgs: [UnsafeMutablePointer?] = args.map { $0.map { strdup($0) } }
+ var cargs: [UnsafePointer?] = mutableArgs.map { $0.map { UnsafePointer($0) } }
+ cargs.append(nil)
+ defer {
+ for pointer in mutableArgs where pointer != nil {
+ free(pointer)
+ }
+ }
+ return cargs.withUnsafeMutableBufferPointer { buffer in
+ lib.command(handle, buffer.baseAddress)
+ }
+ }
+
+ private func setDouble(_ name: String, _ value: Double) {
+ var value = value
+ setProperty(name, format: mpvFormatDouble, value: &value)
+ }
+
+ private func setProperty(_ name: String, format: Int32, value: inout T) {
+ guard let handle else { return }
+ withUnsafeMutablePointer(to: &value) { pointer in
+ _ = lib.setProperty(handle, name, format, pointer)
+ }
+ }
+
+ private func getDouble(_ name: String) -> Double {
+ guard let handle else { return 0 }
+ var value = 0.0
+ let result = lib.getProperty(handle, name, mpvFormatDouble, &value)
+ return result >= 0 && value.isFinite ? value : 0
+ }
+
+ private func getFlag(_ name: String) -> Bool {
+ guard let handle else { return false }
+ var value: Int32 = 0
+ let result = lib.getProperty(handle, name, mpvFormatFlag, &value)
+ return result >= 0 && value != 0
+ }
+}
+
+private func flowMPVGetOpenGLProcAddress(_ ctx: UnsafeMutableRawPointer?, _ name: UnsafePointer?) -> UnsafeMutableRawPointer? {
+ guard let name else { return nil }
+ let symbolName = CFStringCreateWithCString(kCFAllocatorDefault, name, CFStringBuiltInEncodings.ASCII.rawValue)
+ guard let bundle = CFBundleGetBundleWithIdentifier("com.apple.opengl" as CFString) else { return nil }
+ return CFBundleGetFunctionPointerForName(bundle, symbolName)
+}
+
+private func flowMPVRenderUpdateCallback(_ context: UnsafeMutableRawPointer?) {
+ guard let context else { return }
+ let layer = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ layer.update()
+}
+
+private func flowMPVDisplayLinkCallback(
+ _ displayLink: CVDisplayLink,
+ _ inNow: UnsafePointer,
+ _ inOutputTime: UnsafePointer,
+ _ flagsIn: CVOptionFlags,
+ _ flagsOut: UnsafeMutablePointer,
+ _ context: UnsafeMutableRawPointer?
+) -> CVReturn {
+ guard let context else { return kCVReturnSuccess }
+ let view = Unmanaged.fromOpaque(context).takeUnretainedValue()
+ view.reportSwap()
+ return kCVReturnSuccess
+}
diff --git a/FlowVision/Sources/Views/VideoPlayerControlsView.swift b/FlowVision/Sources/Views/VideoPlayerControlsView.swift
index e04817fc..f6d31233 100644
--- a/FlowVision/Sources/Views/VideoPlayerControlsView.swift
+++ b/FlowVision/Sources/Views/VideoPlayerControlsView.swift
@@ -446,17 +446,16 @@ class VideoPlayerControlsView: NSView {
}
func updateABMarkers() {
- guard let player = largeImageView?.queuePlayer,
- let duration = player.currentItem?.duration else {
+ guard let largeImageView = largeImageView else {
abMarkerA.isHidden = true
abMarkerB.isHidden = true
return
}
- let total = CMTimeGetSeconds(duration)
+ let total = largeImageView.videoDurationSeconds
guard total.isFinite && total > 0 else { return }
let progressWidth = progressBarBackground.bounds.width
- if let posA = largeImageView?.abPlayPositionA {
+ if let posA = largeImageView.abPlayPositionA {
let fracA = CGFloat(CMTimeGetSeconds(posA) / total)
abMarkerAConstraint.constant = progressWidth * max(0, min(1, fracA))
abMarkerA.isHidden = false
@@ -464,7 +463,7 @@ class VideoPlayerControlsView: NSView {
abMarkerA.isHidden = true
}
- if let posB = largeImageView?.abPlayPositionB {
+ if let posB = largeImageView.abPlayPositionB {
let fracB = CGFloat(CMTimeGetSeconds(posB) / total)
abMarkerBConstraint.constant = progressWidth * max(0, min(1, fracB))
abMarkerB.isHidden = false
@@ -575,8 +574,8 @@ class VideoPlayerControlsView: NSView {
if expandedProgressFrame.contains(location) {
isDraggingProgress = true
- wasPlayingBeforeDrag = largeImageView?.queuePlayer?.timeControlStatus == .playing
- largeImageView?.queuePlayer?.pause()
+ wasPlayingBeforeDrag = largeImageView?.videoIsPlaying == true
+ largeImageView?.setVideoPaused(true)
seekToPosition(at: location)
}
}
@@ -593,7 +592,7 @@ class VideoPlayerControlsView: NSView {
if isDraggingProgress {
isDraggingProgress = false
if wasPlayingBeforeDrag {
- largeImageView?.queuePlayer?.play()
+ largeImageView?.setVideoPaused(false)
}
updatePlayPauseIcon()
@@ -627,37 +626,36 @@ class VideoPlayerControlsView: NSView {
// MARK: - Progress Bar Interaction
private func seekToPosition(at location: NSPoint) {
- guard let player = largeImageView?.queuePlayer,
- let duration = player.currentItem?.duration else { return }
+ guard let largeImageView = largeImageView else { return }
let progressFrame = progressBarBackground.frame
let relativeX = max(0, min(location.x - progressFrame.origin.x, progressFrame.width))
let fraction = relativeX / progressFrame.width
- let totalDuration = CMTimeGetSeconds(duration)
+ let totalDuration = largeImageView.videoDurationSeconds
+ guard totalDuration.isFinite && totalDuration > 0 else { return }
var targetSeconds = totalDuration * Double(fraction)
- if let posA = largeImageView?.abPlayPositionA, let posB = largeImageView?.abPlayPositionB,
+ if let posA = largeImageView.abPlayPositionA, let posB = largeImageView.abPlayPositionB,
CMTimeGetSeconds(posA) < CMTimeGetSeconds(posB) {
targetSeconds = max(CMTimeGetSeconds(posA), min(CMTimeGetSeconds(posB), targetSeconds))
}
let clampedFraction = CGFloat(targetSeconds / totalDuration)
- let targetTime = CMTimeMakeWithSeconds(targetSeconds, preferredTimescale: 600)
- player.seek(to: targetTime, toleranceBefore: .zero, toleranceAfter: .zero)
+ largeImageView.seekVideo(to: targetSeconds)
updateProgress(fraction: clampedFraction)
}
private func updateHoverTime(at location: NSPoint) {
- guard let player = largeImageView?.queuePlayer,
- let duration = player.currentItem?.duration else { return }
+ guard let largeImageView = largeImageView else { return }
let progressFrame = progressBarBackground.frame
let relativeX = max(0, min(location.x - progressFrame.origin.x, progressFrame.width))
let fraction = relativeX / progressFrame.width
- let totalDuration = CMTimeGetSeconds(duration)
+ let totalDuration = largeImageView.videoDurationSeconds
+ guard totalDuration.isFinite && totalDuration > 0 else { return }
let hoverSeconds = totalDuration * Double(fraction)
hoverTimeLabel.stringValue = formatTime(hoverSeconds)
hoverTimeContainer.isHidden = false
@@ -670,28 +668,28 @@ class VideoPlayerControlsView: NSView {
// MARK: - Actions
@objc private func skipBackwardTapped() {
- guard let player = largeImageView?.queuePlayer else { return }
- let current = CMTimeGetSeconds(player.currentTime())
+ guard let largeImageView = largeImageView else { return }
+ let current = largeImageView.videoCurrentTimeSeconds
var minBound = 0.0
- if let posA = largeImageView?.abPlayPositionA, let posB = largeImageView?.abPlayPositionB,
+ if let posA = largeImageView.abPlayPositionA, let posB = largeImageView.abPlayPositionB,
CMTimeGetSeconds(posA) < CMTimeGetSeconds(posB) {
minBound = CMTimeGetSeconds(posA)
}
let target = max(minBound, current - 15)
- player.seek(to: CMTimeMakeWithSeconds(target, preferredTimescale: 600), toleranceBefore: .zero, toleranceAfter: .zero)
+ largeImageView.seekVideo(to: target)
}
@objc private func skipForwardTapped() {
- guard let player = largeImageView?.queuePlayer,
- let duration = player.currentItem?.duration else { return }
- let current = CMTimeGetSeconds(player.currentTime())
- var maxBound = CMTimeGetSeconds(duration)
- if let posA = largeImageView?.abPlayPositionA, let posB = largeImageView?.abPlayPositionB,
+ guard let largeImageView = largeImageView else { return }
+ let current = largeImageView.videoCurrentTimeSeconds
+ var maxBound = largeImageView.videoDurationSeconds
+ guard maxBound.isFinite && maxBound > 0 else { return }
+ if let posA = largeImageView.abPlayPositionA, let posB = largeImageView.abPlayPositionB,
CMTimeGetSeconds(posA) < CMTimeGetSeconds(posB) {
maxBound = CMTimeGetSeconds(posB)
}
let target = min(maxBound, current + 15)
- player.seek(to: CMTimeMakeWithSeconds(target, preferredTimescale: 600), toleranceBefore: .zero, toleranceAfter: .zero)
+ largeImageView.seekVideo(to: target)
}
@objc private func playPauseTapped() {
@@ -700,20 +698,19 @@ class VideoPlayerControlsView: NSView {
}
@objc private func volumeButtonTapped() {
- guard let player = largeImageView?.queuePlayer else { return }
+ guard let largeImageView = largeImageView else { return }
- if player.volume > 0 {
- volumeBeforeMute = player.volume
- player.volume = 0
+ if largeImageView.videoVolume > 0 {
+ volumeBeforeMute = largeImageView.videoVolume
+ largeImageView.videoVolume = 0
} else {
- player.volume = volumeBeforeMute > 0 ? volumeBeforeMute : 1.0
+ largeImageView.videoVolume = volumeBeforeMute > 0 ? volumeBeforeMute : 1.0
}
updateVolumeUI()
}
@objc private func volumeSliderChanged(_ sender: NSSlider) {
- guard let player = largeImageView?.queuePlayer else { return }
- player.volume = Float(sender.doubleValue)
+ largeImageView?.videoVolume = Float(sender.doubleValue)
updateVolumeIcon()
}
@@ -750,20 +747,18 @@ class VideoPlayerControlsView: NSView {
}
func updatePlayPauseIcon() {
- guard let player = largeImageView?.queuePlayer else { return }
- let symbolName = player.rate > 0 ? "pause.fill" : "play.fill"
+ let symbolName = largeImageView?.videoIsPlaying == true ? "pause.fill" : "play.fill"
playPauseButton.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)
}
func updateVolumeUI() {
- guard let player = largeImageView?.queuePlayer else { return }
- volumeSlider.doubleValue = Double(player.volume)
+ guard let largeImageView = largeImageView else { return }
+ volumeSlider.doubleValue = Double(largeImageView.videoVolume)
updateVolumeIcon()
}
private func updateVolumeIcon() {
- guard let player = largeImageView?.queuePlayer else { return }
- let symbolName = player.volume <= 0 ? "speaker.slash.fill" : "speaker.2.fill"
+ let symbolName = largeImageView?.videoVolume ?? 0 <= 0 ? "speaker.slash.fill" : "speaker.2.fill"
volumeButton.image = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)
}
diff --git a/FlowVision/Sources/WindowController.swift b/FlowVision/Sources/WindowController.swift
index 5abbfe96..c07f47c8 100644
--- a/FlowVision/Sources/WindowController.swift
+++ b/FlowVision/Sources/WindowController.swift
@@ -6,7 +6,7 @@
import Cocoa
class WindowController: NSWindowController, NSWindowDelegate {
-
+
var pathShortenStore = ""
var windowFrameBeforeFullScreen: NSRect?
var cursorHideTimer: Timer?
@@ -14,11 +14,11 @@ class WindowController: NSWindowController, NSWindowDelegate {
override func windowDidLoad() {
super.windowDidLoad()
-
+
log("Start windowDidLoad")
-
+
self.window?.delegate = self
-
+
window?.title = ""
if let window = self.window {
@@ -27,7 +27,7 @@ class WindowController: NSWindowController, NSWindowDelegate {
window.titleVisibility = .hidden
window.titlebarAppearsTransparent = false
window.isMovableByWindowBackground = false
-
+
// 创建并配置工具栏
// Create and configure toolbar
globalVar.toolbarIndex += 1
@@ -48,7 +48,7 @@ class WindowController: NSWindowController, NSWindowDelegate {
window.tabbingMode = .preferred
}
}
-
+
if globalVar.portableMode && globalVar.startSpeedUpImageSizeCache != nil {
if let viewController = contentViewController as? ViewController {
viewController.adjustWindowPortable(refSize: globalVar.startSpeedUpImageSizeCache, firstShowThumb: false, animate: false, justAdjustWindowFrame: true, isToCenter: true)
@@ -64,21 +64,21 @@ class WindowController: NSWindowController, NSWindowDelegate {
}
}
}
-
+
// 设置焦点
// Set focus
if let viewController = contentViewController as? ViewController {
window?.makeFirstResponder(viewController.collectionView)
}
-
+
log("End windowDidLoad")
}
-
+
func prepareForDeinit() {
saveWindowState()
cancelCursorHideTimer()
}
-
+
func saveWindowState() {
guard let window = self.window else { return }
if let viewController = contentViewController as? ViewController {
@@ -89,7 +89,7 @@ class WindowController: NSWindowController, NSWindowDelegate {
let frame = NSStringFromRect(window.frame)
UserDefaults.standard.set(frame, forKey: "windowFrame")
}
-
+
func windowWillClose(_ notification: Notification) {
// 移除引用
// Remove reference
@@ -99,7 +99,7 @@ class WindowController: NSWindowController, NSWindowDelegate {
appDelegate.removeWindowController(self)
}
}
-
+
// 在窗口关闭时执行清理,例如,保存数据、释放资源等
// Perform cleanup when window closes, e.g., save data, release resources
if let viewController = contentViewController as? ViewController {
@@ -107,7 +107,7 @@ class WindowController: NSWindowController, NSWindowDelegate {
viewController.prepareForDeinit()
}
self.prepareForDeinit()
-
+
globalVar.windowNum -= 1
log("Window closed, remain: " + String(globalVar.windowNum))
if globalVar.windowNum == 0 && globalVar.terminateAfterLastWindowClosed {
@@ -118,11 +118,11 @@ class WindowController: NSWindowController, NSWindowDelegate {
}
}
}
-
+
func windowDidBecomeKey(_ notification: Notification) {
log("windowDidBecomeKey")
}
-
+
func toggleWindowOnTop() {
guard let viewController = contentViewController as? ViewController else {return}
viewController.toggleOnTop()
@@ -136,12 +136,12 @@ class WindowController: NSWindowController, NSWindowDelegate {
// Save current window size
windowFrameBeforeFullScreen = window.frame
}
-
+
// 在窗口已经进入全屏模式时执行
// Execute when window has entered full screen mode
func windowDidEnterFullScreen(_ notification: Notification) {
guard let viewController = contentViewController as? ViewController else {return}
-
+
// 启动延迟隐藏光标的定时器
// Start timer to delay hiding cursor
scheduleCursorHide()
@@ -159,12 +159,12 @@ class WindowController: NSWindowController, NSWindowDelegate {
}
}
}
-
+
// 在窗口已经退出全屏模式时执行
// Execute when window has exited full screen mode
func windowDidExitFullScreen(_ notification: Notification) {
guard let viewController = contentViewController as? ViewController else {return}
-
+
// 取消光标隐藏定时器并显示光标
// Cancel cursor hide timer and show cursor
cancelCursorHideTimer()
@@ -179,7 +179,7 @@ class WindowController: NSWindowController, NSWindowDelegate {
}
}
}
-
+
if viewController.publicVar.isInLargeView {
if viewController.largeImageView.file.type == .image {
viewController.changeLargeImage(firstShowThumb: false, resetSize: true, triggeredByLongPress: false)
@@ -194,26 +194,26 @@ class WindowController: NSWindowController, NSWindowDelegate {
// showTitleBar()
// }
}
-
+
override func mouseExited(with event: NSEvent) {
// if globalVar.autoHideToolbar {
// hideTitleBar()
// }
}
-
+
override func mouseMoved(with event: NSEvent) {
guard let window = window else { return }
guard let toolbar = window.toolbar else { return }
guard let viewController = contentViewController as? ViewController else {return}
let location = event.locationInWindow
-
+
// 在全屏模式下,鼠标移动时显示光标并重置隐藏定时器
// In full screen mode, show cursor when mouse moves and reset hide timer
if window.styleMask.contains(.fullScreen) {
NSCursor.unhide()
scheduleCursorHide()
}
-
+
if globalVar.autoHideToolbar {
if location.y > window.frame.height - 40 {
showTitleBar()
@@ -236,7 +236,7 @@ class WindowController: NSWindowController, NSWindowDelegate {
}
}
}
-
+
// 显示标题栏和工具栏
// Show title bar and toolbar
func showTitleBar() {
@@ -249,7 +249,7 @@ class WindowController: NSWindowController, NSWindowDelegate {
window.titlebarAppearsTransparent = false
toolbar.isVisible = true
}
-
+
// 隐藏标题栏和工具栏
// Hide title bar and toolbar
func hideTitleBar() {
@@ -262,7 +262,7 @@ class WindowController: NSWindowController, NSWindowDelegate {
window.titlebarAppearsTransparent = true
toolbar.isVisible = false
}
-
+
// 安排延迟隐藏光标(在全屏模式下,鼠标停止移动后1秒隐藏)
// Schedule delayed cursor hiding (in full screen mode, hide cursor 1 second after mouse stops moving)
func scheduleCursorHide() {
@@ -279,7 +279,7 @@ class WindowController: NSWindowController, NSWindowDelegate {
}
}
}
-
+
// 取消光标隐藏定时器
// Cancel cursor hide timer
func cancelCursorHideTimer() {
@@ -309,6 +309,9 @@ extension NSToolbarItem.Identifier {
static let favorites = NSToolbarItem.Identifier("com.example.favorites")
static let tagging = NSToolbarItem.Identifier("com.example.tagging")
static let thumbSize = NSToolbarItem.Identifier("com.example.thumbSize")
+ static let batchRotate = NSToolbarItem.Identifier("com.example.batchRotate")
+ static let videoCropSize = NSToolbarItem.Identifier("com.example.videoCropSize")
+ static let quickRename = NSToolbarItem.Identifier("com.example.quickRename")
static let isRecursiveMode = NSToolbarItem.Identifier("com.example.isRecursiveMode")
static let isSearchFilterOn = NSToolbarItem.Identifier("com.example.isSearchFilterOn")
static let isTagFilterOn = NSToolbarItem.Identifier("com.example.isTagFilterOn")
@@ -318,21 +321,21 @@ extension NSToolbarItem.Identifier {
}
extension WindowController: NSToolbarDelegate {
-
+
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
return getItemIdentifiers()
}
-
+
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
return getItemIdentifiers()
}
-
+
func getItemIdentifiers() -> [NSToolbarItem.Identifier] {
// , .flexibleSpace, .space
var identifiers: [NSToolbarItem.Identifier] = [.sidebar, .favorites, .goBack, .goForward]
-
+
// identifiers.append(.upFolder)
-
+
if let viewController = contentViewController as? ViewController {
if viewController.publicVar.isInLargeView {
identifiers.append(.windowTitle)
@@ -350,6 +353,9 @@ extension WindowController: NSToolbarDelegate {
}
// identifiers.append(.rotateL)
identifiers.append(.rotateR)
+ if viewController.largeImageView.file.type == .video {
+ identifiers.append(.videoCropSize)
+ }
identifiers.append(.showinfo)
}else{
if viewController.publicVar.profile.getValue(forKey: "isWindowTitleUseFullPath") == "true" {
@@ -361,7 +367,7 @@ extension WindowController: NSToolbarDelegate {
}else{
identifiers.append(.windowTitle)
}
-
+
if viewController.publicVar.autoPlayVisibleVideo {
identifiers.append(.isAutoPlayVisibleVideo)
}
@@ -380,53 +386,60 @@ extension WindowController: NSToolbarDelegate {
identifiers.append(.tagging)
identifiers.append(.viewToggle)
identifiers.append(.thumbSize)
+ if viewController.hasSelectedRotatableMedia() {
+ identifiers.append(.batchRotate)
+ }
+ if viewController.hasSelectedVideoMedia() {
+ identifiers.append(.videoCropSize)
+ }
+ identifiers.append(.quickRename)
identifiers.append(.sort)
}
}
-
+
if #available(macOS 26.0, *) {
identifiers.append(.space)
}else{
identifiers.append(NSToolbarItem.Identifier("CustomSeparator"))
}
-
+
identifiers.append(.more)
identifiers.append(.newtab)
-
+
return identifiers
}
-
+
func updateToolbar() {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
updateToolbarSync()
}
}
-
+
func updateToolbarSync() {
guard let toolbar = window?.toolbar else { return }
let itemIdentifiers = getItemIdentifiers()
-
+
while toolbar.items.count > 0 {
toolbar.removeItem(at: 0)
}
-
+
for (index, identifier) in itemIdentifiers.enumerated() {
toolbar.insertItem(withItemIdentifier: identifier, at: index)
}
-
+
adjustPathControlWidth()
}
-
+
func adjustPathControlWidth() {
guard let toolbar = window?.toolbar,
let window = window else { return }
-
+
guard let pathControlItem = toolbar.items.first(where: { $0.itemIdentifier == .pathControl }),
let pathControl = pathControlItem.view as? CustomPathControl else { return }
-
+
let font = NSFont.systemFont(ofSize: 13, weight: .regular)
-
+
var otherItemsWidth: CGFloat = 0
for item in toolbar.items {
if item.itemIdentifier == .pathControl || item.itemIdentifier == .flexibleSpace { continue }
@@ -434,7 +447,7 @@ extension WindowController: NSToolbarDelegate {
otherItemsWidth += view.fittingSize.width
}
}
-
+
let itemCount = toolbar.items.filter { $0.itemIdentifier != .flexibleSpace }.count
let spacingValue: CGFloat
if #available(macOS 26.0, *) {
@@ -444,13 +457,13 @@ extension WindowController: NSToolbarDelegate {
}
let interItemSpacing = CGFloat(itemCount) * spacingValue
let maxWidth = window.frame.width - otherItemsWidth - interItemSpacing - 20
-
+
var pathItems = pathControl.fullPathItems
guard !pathItems.isEmpty else { return }
-
+
var totalWidth: CGFloat = 0
var startIndex = pathItems.count - 1
-
+
for i in (0.. maxWidth && startIndex != 0 {
let ellipsisItem = CustomPathControlItem()
ellipsisItem.title = "..."
ellipsisItem.myUrl = pathItems[startIndex].myUrl?.deletingLastPathComponent()
pathItems = [ellipsisItem] + pathItems[startIndex...]
}
-
+
pathControl.pathItems = pathItems
-
+
let titleFontColor = NSColor.labelColor
for item in pathControl.pathItems {
let range = NSMakeRange(0, item.attributedTitle.length)
@@ -482,24 +495,24 @@ extension WindowController: NSToolbarDelegate {
item.attributedTitle = attributedTitle
}
}
-
+
func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
let toolbarItem = NSToolbarItem(itemIdentifier: itemIdentifier)
guard let viewController = contentViewController as? ViewController else {return toolbarItem}
-
+
// let titleFontColor = NSApp.effectiveAppearance.name == .darkAqua ? hexToNSColor(hex: "#FFFFFF", alpha: 0.847) : hexToNSColor(hex: "#000000", alpha: 0.847)
// let titleFontColor = NSApp.effectiveAppearance.name == .darkAqua ? hexToNSColor(hex: "#FFFFFF", alpha: 0.64) : hexToNSColor(hex: "#000000", alpha: 0.6)
let titleFontColor = NSColor.labelColor
// let titleFontColor = NSColor.controlTextColor
-
+
switch itemIdentifier {
-
+
case .windowTitle:
let title = (contentViewController as? ViewController)?.publicVar.toolbarTitle ?? "FlowVision"
let isInLargeView = viewController.publicVar.isInLargeView
let showExtra = isInLargeView || viewController.publicVar.profile.getValue(forKey: "isWindowTitleShowStatistics") == "true"
let statisticInfo = viewController.publicVar.titleStatisticInfo
-
+
let font = NSFont.systemFont(ofSize: 13, weight: .regular)
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .center
@@ -520,7 +533,7 @@ extension WindowController: NSToolbarDelegate {
attributedString.append(statAttr)
}
}
-
+
let titleLabel = createWindowTitleLabel(string: "")
titleLabel.attributedStringValue = attributedString
toolbarItem.view = titleLabel
@@ -531,7 +544,7 @@ extension WindowController: NSToolbarDelegate {
toolbarItem.label = NSLocalizedString("Window Title", comment: "窗口标题")
toolbarItem.paletteLabel = NSLocalizedString("Window Title", comment: "窗口标题")
toolbarItem.visibilityPriority = .high
-
+
case .windowTitleStatistics:
let text = (contentViewController as? ViewController)?.publicVar.titleStatisticInfo
let titleLabel = createWindowTitleLabel(string: text ?? "")
@@ -546,7 +559,7 @@ extension WindowController: NSToolbarDelegate {
toolbarItem.label = NSLocalizedString("Window Title", comment: "窗口标题")
toolbarItem.paletteLabel = NSLocalizedString("Window Title", comment: "窗口标题")
toolbarItem.visibilityPriority = .high
-
+
case .pathControl:
let pathControl = CustomPathControl()
pathControl.pathStyle = .standard
@@ -556,7 +569,7 @@ extension WindowController: NSToolbarDelegate {
pathControl.target = self
pathControl.action = #selector(pathControlClicked(_:))
let font = NSFont.systemFont(ofSize: 13, weight: .regular)
-
+
if let viewController = contentViewController as? ViewController {
viewController.fileDB.lock()
let curFolder = viewController.fileDB.curFolder
@@ -570,9 +583,13 @@ extension WindowController: NSToolbarDelegate {
}
let components = pathString.components(separatedBy: "/")
var pathItems: [CustomPathControlItem] = []
-
+
let isVirtualFinderTagsFolder = pathString.hasPrefix("VirtualFinderTagsFolder")
-
+ let isVirtualFavoritesFolder = pathString.hasPrefix("VirtualFavoritesFolder")
+ let isVirtualHistoryFolder = pathString.hasPrefix("VirtualHistoryFolder")
+ let isVirtualArchiveFolder = pathString.hasPrefix("VirtualArchiveFolder")
+ let isVirtualFolder = isVirtualFinderTagsFolder || isVirtualFavoritesFolder || isVirtualHistoryFolder || isVirtualArchiveFolder
+
for (i,component) in components.enumerated() {
if component == "" {continue}
let item = CustomPathControlItem()
@@ -584,22 +601,32 @@ extension WindowController: NSToolbarDelegate {
if isVirtualFinderTagsFolder && i == 0 {
item.title = NSLocalizedString("Finder Tags", comment: "Finder标签")
+ } else if isVirtualFavoritesFolder && i == 0 {
+ item.title = NSLocalizedString("Favorites", comment: "收藏")
+ } else if isVirtualHistoryFolder && i == 0 {
+ item.title = NSLocalizedString("History", comment: "历史")
+ } else if isVirtualArchiveFolder && i == 0 {
+ item.title = NSLocalizedString("Archive", comment: "压缩包")
+ } else if isVirtualArchiveFolder && i == 1,
+ let archivePath = component.removingPercentEncoding,
+ let archiveURL = URL(string: archivePath) {
+ item.title = archiveURL.lastPathComponent
}
pathItems.append(item)
}
-
- if !isVirtualFinderTagsFolder {
+
+ if !isVirtualFolder {
let rootItem = CustomPathControlItem()
rootItem.title = ROOT_NAME
rootItem.myUrl = URL(string: "file:///")
pathItems.insert(rootItem, at: 0)
}
-
+
pathItems.last?.myUrl = nil
pathControl.fullPathItems = pathItems
}
-
+
for item in pathControl.pathItems {
let range = NSMakeRange(0, item.attributedTitle.length)
let attributedTitle = NSMutableAttributedString(attributedString: item.attributedTitle)
@@ -607,12 +634,12 @@ extension WindowController: NSToolbarDelegate {
attributedTitle.addAttribute(.font, value: font, range: range)
item.attributedTitle = attributedTitle
}
-
+
toolbarItem.view = pathControl
toolbarItem.label = NSLocalizedString("Window Title", comment: "窗口标题")
- toolbarItem.paletteLabel = NSLocalizedString("Window Title", comment: "窗口标题")
+ toolbarItem.paletteLabel = NSLocalizedString("Window Title", comment: "窗口标题")
toolbarItem.visibilityPriority = .high
-
+
case .sidebar:
let button = NSButton(title: "", image: NSImage(systemSymbolName: "sidebar.left", accessibilityDescription: "")!, target: self, action: #selector(sidebarAction(_:)))
setButtonStyle(button)
@@ -623,7 +650,7 @@ extension WindowController: NSToolbarDelegate {
toolbarItem.paletteLabel = NSLocalizedString("Sidebar", comment: "侧边栏")
toolbarItem.isNavigational = true
toolbarItem.visibilityPriority = .low
-
+
case .favorites:
let button = NSButton(title: "", image: NSImage(systemSymbolName: "star", accessibilityDescription: "")!, target: self, action: #selector(favoritesAction(_:)))
setButtonStyle(button)
@@ -633,7 +660,7 @@ extension WindowController: NSToolbarDelegate {
toolbarItem.paletteLabel = NSLocalizedString("Favorites", comment: "收藏夹")
toolbarItem.isNavigational = true
toolbarItem.visibilityPriority = .low
-
+
case .goBack:
let button = NSButton(title: "", image: NSImage(systemSymbolName: "chevron.backward", accessibilityDescription: "")!, target: self, action: #selector(goBackAction(_:)))
setButtonStyle(button)
@@ -644,7 +671,7 @@ extension WindowController: NSToolbarDelegate {
toolbarItem.paletteLabel = NSLocalizedString("Go Back", comment: "后退")
toolbarItem.isNavigational = true
toolbarItem.visibilityPriority = .low
-
+
case .goForward:
let button = NSButton(title: "", image: NSImage(systemSymbolName: "chevron.forward", accessibilityDescription: "")!, target: self, action: #selector(goForwardAction(_:)))
setButtonStyle(button)
@@ -655,7 +682,7 @@ extension WindowController: NSToolbarDelegate {
toolbarItem.paletteLabel = NSLocalizedString("Go Forward", comment: "前进")
toolbarItem.isNavigational = true
toolbarItem.visibilityPriority = .low
-
+
case .upFolder:
let button = NSButton(title: "", image: NSImage(systemSymbolName: "chevron.up", accessibilityDescription: "")!, target: self, action: #selector(upFolderAction(_:)))
setButtonStyle(button)
@@ -675,7 +702,7 @@ extension WindowController: NSToolbarDelegate {
toolbarItem.label = NSLocalizedString("Tagging", comment: "标签")
toolbarItem.paletteLabel = NSLocalizedString("Tagging", comment: "标签")
toolbarItem.visibilityPriority = .low
-
+
case .viewToggle:
let segmentedControl = NSSegmentedControl(images: [
// NSImage(systemSymbolName: "rectangle.grid.1x2", accessibilityDescription: "Justified")!,
@@ -693,7 +720,7 @@ extension WindowController: NSToolbarDelegate {
toolbarItem.paletteLabel = NSLocalizedString("View", comment: "视图")
// toolbarItem.toolTip = NSLocalizedString("View", comment: "视图")
toolbarItem.visibilityPriority = .low
-
+
case .ontop:
var image: NSImage
if window?.level == .floating {
@@ -724,7 +751,7 @@ extension WindowController: NSToolbarDelegate {
toolbarItem.label = NSLocalizedString("Enable HDR", comment: "启用HDR")
toolbarItem.paletteLabel = NSLocalizedString("Enable HDR", comment: "启用HDR")
toolbarItem.visibilityPriority = .low
-
+
case .showinfo:
var image: NSImage
if viewController.publicVar.isShowExif {
@@ -739,7 +766,7 @@ extension WindowController: NSToolbarDelegate {
toolbarItem.label = NSLocalizedString("Show Info", comment: "显示信息")
toolbarItem.paletteLabel = NSLocalizedString("Show Info", comment: "显示信息")
toolbarItem.visibilityPriority = .standard
-
+
case .rotateL:
let button = NSButton(title: "", image: NSImage(systemSymbolName: "arrow.counterclockwise", accessibilityDescription: "")!, target: self, action: #selector(rotateLAction(_:)))
setButtonStyle(button)
@@ -748,7 +775,7 @@ extension WindowController: NSToolbarDelegate {
toolbarItem.label = NSLocalizedString("Rotate Counterclockwise", comment: "逆时针旋转")
toolbarItem.paletteLabel = NSLocalizedString("Rotate Counterclockwise", comment: "逆时针旋转")
toolbarItem.visibilityPriority = .low
-
+
case .rotateR:
let button = NSButton(title: "", image: NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "")!, target: self, action: #selector(rotateRAction(_:)))
setButtonStyle(button)
@@ -757,7 +784,7 @@ extension WindowController: NSToolbarDelegate {
toolbarItem.label = NSLocalizedString("Rotate Clockwise", comment: "顺时针旋转")
toolbarItem.paletteLabel = NSLocalizedString("Rotate Clockwise", comment: "顺时针旋转")
toolbarItem.visibilityPriority = .low
-
+
case .zoomIn:
let button = NSButton(title: "", image: NSImage(systemSymbolName: "plus", accessibilityDescription: "")!, target: self, action: #selector(zoomInAction(_:)))
setButtonStyle(button)
@@ -766,7 +793,7 @@ extension WindowController: NSToolbarDelegate {
toolbarItem.label = NSLocalizedString("Zoom In", comment: "放大")
toolbarItem.paletteLabel = NSLocalizedString("Zoom In", comment: "放大")
toolbarItem.visibilityPriority = .low
-
+
case .zoomOut:
let button = NSButton(title: "", image: NSImage(systemSymbolName: "minus", accessibilityDescription: "")!, target: self, action: #selector(zoomOutAction(_:)))
setButtonStyle(button)
@@ -775,7 +802,7 @@ extension WindowController: NSToolbarDelegate {
toolbarItem.label = NSLocalizedString("Zoom Out", comment: "缩小")
toolbarItem.paletteLabel = NSLocalizedString("Zoom Out", comment: "缩小")
toolbarItem.visibilityPriority = .low
-
+
case .sort:
var title = ""
var image = NSImage(systemSymbolName: "arrow.up.arrow.down", accessibilityDescription: "")!
@@ -812,10 +839,10 @@ extension WindowController: NSToolbarDelegate {
image = NSImage(systemSymbolName: "arrow.2.circlepath", accessibilityDescription: "")!
}
}
-
+
let button = NSButton(title: title, image: image, target: self, action: #selector(showSortMenu(_:)))
setButtonStyle(button)
-
+
// 自定义title的字体大小和颜色
// Customize title font size and color
let font = NSFont.systemFont(ofSize: 13)
@@ -829,7 +856,7 @@ extension WindowController: NSToolbarDelegate {
toolbarItem.label = NSLocalizedString("Sort Order", comment: "排序方式")
toolbarItem.paletteLabel = NSLocalizedString("Sort Order", comment: "排序方式")
toolbarItem.visibilityPriority = .low
-
+
case .thumbSize:
let button = NSButton(title: "", image: NSImage(systemSymbolName: "photo", accessibilityDescription: "")!, target: self, action: #selector(showThumbSizeMenu(_:)))
setButtonStyle(button)
@@ -838,7 +865,34 @@ extension WindowController: NSToolbarDelegate {
toolbarItem.label = NSLocalizedString("Thumbnail Size", comment: "缩略图大小")
toolbarItem.paletteLabel = NSLocalizedString("Thumbnail Size", comment: "缩略图大小")
toolbarItem.visibilityPriority = .low
-
+
+ case .batchRotate:
+ let button = NSButton(title: "", image: NSImage(systemSymbolName: "rotate.right", accessibilityDescription: "")!, target: self, action: #selector(showBatchRotateMenu(_:)))
+ setButtonStyle(button)
+ button.toolTip = NSLocalizedString("Rotate Selected Media", comment: "旋转选中的媒体")
+ toolbarItem.view = button
+ toolbarItem.label = NSLocalizedString("Rotate", comment: "旋转")
+ toolbarItem.paletteLabel = NSLocalizedString("Rotate", comment: "旋转")
+ toolbarItem.visibilityPriority = .low
+
+ case .videoCropSize:
+ let button = NSButton(title: "", image: NSImage(systemSymbolName: "crop", accessibilityDescription: "")!, target: self, action: #selector(cropSelectedVideos(_:)))
+ setButtonStyle(button)
+ button.toolTip = NSLocalizedString("Crop Video Size", comment: "裁剪视频尺寸")
+ toolbarItem.view = button
+ toolbarItem.label = NSLocalizedString("Crop Size", comment: "裁剪尺寸")
+ toolbarItem.paletteLabel = NSLocalizedString("Crop Size", comment: "裁剪尺寸")
+ toolbarItem.visibilityPriority = .low
+
+ case .quickRename:
+ let button = NSButton(title: "", image: NSImage(systemSymbolName: "textformat.123", accessibilityDescription: "")!, target: self, action: #selector(quickRenameAction(_:)))
+ setButtonStyle(button)
+ button.toolTip = NSLocalizedString("Quick Rename", comment: "快速重命名")
+ toolbarItem.view = button
+ toolbarItem.label = NSLocalizedString("Quick Rename", comment: "快速重命名")
+ toolbarItem.paletteLabel = NSLocalizedString("Quick Rename", comment: "快速重命名")
+ toolbarItem.visibilityPriority = .low
+
case .isAutoPlayVisibleVideo:
let button = NSButton(title: "", image: NSImage(systemSymbolName: "video.circle.fill", accessibilityDescription: "")!, target: self, action: #selector(toggleAutoPlayVisibleVideo(_:)))
setButtonStyle(button)
@@ -847,7 +901,7 @@ extension WindowController: NSToolbarDelegate {
toolbarItem.label = NSLocalizedString("Cancel Auto Play Visible Video", comment: "取消自动播放可见视频")
toolbarItem.paletteLabel = NSLocalizedString("Cancel Auto Play Visible Video", comment: "取消自动播放可见视频")
toolbarItem.visibilityPriority = .low
-
+
case .isSearchFilterOn:
let button = NSButton(title: "", image: NSImage(systemSymbolName: "magnifyingglass.circle.fill", accessibilityDescription: "")!, target: self, action: #selector(toggleSearchFilter(_:)))
setButtonStyle(button)
@@ -867,7 +921,7 @@ extension WindowController: NSToolbarDelegate {
toolbarItem.label = NSLocalizedString("Cancel Filter", comment: "取消过滤")
toolbarItem.paletteLabel = NSLocalizedString("Cancel Filter", comment: "取消过滤")
toolbarItem.visibilityPriority = .low
-
+
case .isRatingFilterOn:
let button = NSButton(title: "", image: NSImage(systemSymbolName: "star.circle.fill", accessibilityDescription: "")!, target: self, action: #selector(toggleClearRatingFilter(_:)))
setButtonStyle(button)
@@ -886,7 +940,7 @@ extension WindowController: NSToolbarDelegate {
toolbarItem.label = NSLocalizedString("Exit Recursive Mode", comment: "退出递归浏览模式")
toolbarItem.paletteLabel = NSLocalizedString("Exit Recursive Mode", comment: "退出递归浏览模式")
toolbarItem.visibilityPriority = .low
-
+
case .more:
let button = NSButton(title: "", image: NSImage(systemSymbolName: "ellipsis.circle", accessibilityDescription: "")!, target: self, action: #selector(showMoreMenu(_:)))
setButtonStyle(button)
@@ -895,7 +949,7 @@ extension WindowController: NSToolbarDelegate {
toolbarItem.label = NSLocalizedString("More", comment: "更多")
toolbarItem.paletteLabel = NSLocalizedString("More", comment: "更多")
toolbarItem.visibilityPriority = .high
-
+
case .newtab:
let button = NSButton(title: "", image: NSImage(systemSymbolName: "rectangle.badge.plus", accessibilityDescription: "")!, target: self, action: #selector(newtabAction(_:)))
setButtonStyle(button)
@@ -904,7 +958,7 @@ extension WindowController: NSToolbarDelegate {
toolbarItem.label = NSLocalizedString("New Tab", comment: "新标签页")
toolbarItem.paletteLabel = NSLocalizedString("New Tab", comment: "新标签页")
toolbarItem.visibilityPriority = .high
-
+
case NSToolbarItem.Identifier("CustomSeparator"):
let margin: CGFloat = 4
let lineWidth: CGFloat = 1
@@ -918,13 +972,13 @@ extension WindowController: NSToolbarDelegate {
containerView.addSubview(line)
toolbarItem.view = containerView
toolbarItem.visibilityPriority = .low
-
+
default:
return nil
}
return toolbarItem
}
-
+
@objc func pathControlClicked(_ sender: NSPathControl) {
guard let viewController = contentViewController as? ViewController else {return}
if let clickedItem = sender.clickedPathItem as? CustomPathControlItem {
@@ -936,7 +990,7 @@ extension WindowController: NSToolbarDelegate {
}
}
}
-
+
class NonClickableTextField: NSTextField {
override func hitTest(_ point: NSPoint) -> NSView? {
// 忽略所有鼠标事件
@@ -944,7 +998,7 @@ extension WindowController: NSToolbarDelegate {
return nil
}
}
-
+
private func createWindowTitleLabel(string: String) -> NSTextField {
let titleLabel = NonClickableTextField(labelWithString: string)
titleLabel.isBezeled = false
@@ -955,7 +1009,7 @@ extension WindowController: NSToolbarDelegate {
titleLabel.translatesAutoresizingMaskIntoConstraints = false
return titleLabel
}
-
+
func setButtonStyle(_ button: NSButton) {
button.bezelStyle = .rounded
button.setButtonType(.momentaryPushIn)
@@ -963,17 +1017,17 @@ extension WindowController: NSToolbarDelegate {
// button.bezelStyle = .toolbar
button.showsBorderOnlyWhileMouseInside = true
}
-
+
@objc func sidebarAction(_ sender: Any?) {
if let viewController = contentViewController as? ViewController {
viewController.toggleSidebar()
}
}
-
+
@objc func ontopAction(_ sender: Any?) {
toggleWindowOnTop()
}
-
+
@objc func goBackAction(_ sender: Any?) {
if let viewController = contentViewController as? ViewController {
viewController.handleHistoryBack()
@@ -991,7 +1045,7 @@ extension WindowController: NSToolbarDelegate {
viewController.switchDirByDirection(direction: .up, stackDeep: 0)
}
}
-
+
@objc func newtabAction(_ sender: Any?) {
if let appDelegate = NSApplication.shared.delegate as? AppDelegate,
let viewController = contentViewController as? ViewController {
@@ -1001,37 +1055,37 @@ extension WindowController: NSToolbarDelegate {
appDelegate.createNewWindow(curFolder)
}
}
-
+
@objc func showinfoAction(_ sender: Any?) {
if let viewController = contentViewController as? ViewController {
viewController.largeImageView.actShowExif()
}
}
-
+
@objc func rotateLAction(_ sender: Any?) {
if let viewController = contentViewController as? ViewController {
viewController.largeImageView.actRotateL()
}
}
-
+
@objc func rotateRAction(_ sender: Any?) {
if let viewController = contentViewController as? ViewController {
viewController.largeImageView.actRotateR()
}
}
-
+
@objc func zoomInAction(_ sender: Any?) {
if let viewController = contentViewController as? ViewController {
viewController.largeImageView.zoom(direction: +1)
}
}
-
+
@objc func zoomOutAction(_ sender: Any?) {
if let viewController = contentViewController as? ViewController {
viewController.largeImageView.zoom(direction: -1)
}
}
-
+
@objc func viewToggleAction(_ sender: NSSegmentedControl) {
guard let viewController = contentViewController as? ViewController else {return}
switch sender.selectedSegment {
@@ -1051,7 +1105,7 @@ extension WindowController: NSToolbarDelegate {
break
}
}
-
+
@objc func showSortMenu(_ sender: Any?) {
guard let viewController = contentViewController as? ViewController else {return}
// 图标映射
@@ -1096,7 +1150,7 @@ extension WindowController: NSToolbarDelegate {
(.tagZ, NSLocalizedString("sort-tagZ", comment: "Finder标签(倒序)")),
(.random, NSLocalizedString("sort-random", comment: "随机"))
]
-
+
let exifSortTypes: [(SortType, String)] = [
(.exifDateA, NSLocalizedString("sort-exifDateA", comment: "Exif日期")),
(.exifDateZ, NSLocalizedString("sort-exifDateZ", comment: "Exif日期(倒序)")),
@@ -1105,9 +1159,9 @@ extension WindowController: NSToolbarDelegate {
(.ratingA, NSLocalizedString("sort-ratingA", comment: "XMP评级")),
(.ratingZ, NSLocalizedString("sort-ratingZ", comment: "XMP评级(倒序)"))
]
-
+
let menu = NSMenu()
-
+
let folderFirstItem = NSMenuItem(title: NSLocalizedString("Sort Folders First", comment: "文件夹优先排序"), action: #selector(sortFolderFirst(_:)), keyEquivalent: "")
folderFirstItem.state = viewController.publicVar.profile.isSortFolderFirst ? .on : .off
menu.addItem(folderFirstItem)
@@ -1117,9 +1171,9 @@ extension WindowController: NSToolbarDelegate {
menu.addItem(sortUseFullPathItem)
let sortReadme = menu.addItem(withTitle: NSLocalizedString("Readme...", comment: "说明..."), action: #selector(sortReadmeAction), keyEquivalent: "")
-
+
menu.addItem(NSMenuItem.separator())
-
+
for (sortType, title) in sortTypes {
let menuItem = NSMenuItem(title: title, action: #selector(sortItems(_:)), keyEquivalent: "")
menuItem.target = self
@@ -1131,14 +1185,14 @@ extension WindowController: NSToolbarDelegate {
}
menu.addItem(menuItem)
}
-
+
// 添加 EXIF 排序子菜单
// Add EXIF sorting submenu
let exifSubmenu = NSMenu()
let exifMenuItem = NSMenuItem(title: NSLocalizedString("Sort by EXIF Info", comment: "根据Exif信息排序"), action: nil, keyEquivalent: "")
exifMenuItem.image = NSImage(systemSymbolName: "camera", accessibilityDescription: "")
exifMenuItem.submenu = exifSubmenu
-
+
for (sortType, title) in exifSortTypes {
let menuItem = NSMenuItem(title: title, action: #selector(sortItems(_:)), keyEquivalent: "")
menuItem.target = self
@@ -1150,9 +1204,9 @@ extension WindowController: NSToolbarDelegate {
}
exifSubmenu.addItem(menuItem)
}
-
+
menu.addItem(exifMenuItem)
-
+
if let button = sender as? NSButton {
let buttonFrame = button.convert(button.bounds, to: nil)
let menuLocation = NSPoint(x: 0, y: buttonFrame.height + 4)
@@ -1166,32 +1220,32 @@ extension WindowController: NSToolbarDelegate {
@objc func sortReadmeAction(_ sender: NSMenuItem) {
showInformationLong(title: NSLocalizedString("Info", comment: "说明"), message: NSLocalizedString("sort-readme", comment: "排序说明..."))
}
-
+
@objc func sortFolderFirst(_ sender: NSMenuItem) {
guard let viewController = contentViewController as? ViewController else {return}
viewController.publicVar.profile.isSortFolderFirst.toggle()
viewController.changeSortType(sortType: viewController.publicVar.profile.sortType, isSortFolderFirst: viewController.publicVar.profile.isSortFolderFirst, isSortUseFullPath: viewController.publicVar.profile.isSortUseFullPath)
}
-
+
@objc func sortUseFullPath(_ sender: NSMenuItem) {
guard let viewController = contentViewController as? ViewController else {return}
viewController.publicVar.profile.isSortUseFullPath.toggle()
viewController.changeSortType(sortType: viewController.publicVar.profile.sortType, isSortFolderFirst: viewController.publicVar.profile.isSortFolderFirst, isSortUseFullPath: viewController.publicVar.profile.isSortUseFullPath)
}
-
+
@objc func sortItems(_ sender: NSMenuItem) {
guard let sortType = sender.representedObject as? SortType else { return }
guard let viewController = contentViewController as? ViewController else {return}
viewController.changeSortType(sortType: sortType, isSortFolderFirst: viewController.publicVar.profile.isSortFolderFirst, isSortUseFullPath: viewController.publicVar.profile.isSortUseFullPath)
}
-
+
@objc func favoritesAction(_ sender: Any?) {
if let existingPopover = favoritesPopover, existingPopover.isShown {
existingPopover.close()
favoritesPopover = nil
return
}
-
+
let favVC = FavoritesPopoverViewController()
favVC.onNavigate = { [weak self] path in
guard let self = self,
@@ -1209,7 +1263,7 @@ extension WindowController: NSToolbarDelegate {
viewController.fileDB.unlock()
return curFolder
}
-
+
let popover = NSPopover()
popover.contentViewController = favVC
// Anchor to window contentView so auto-hiding toolbar won't immediately dismiss it.
@@ -1217,11 +1271,11 @@ extension WindowController: NSToolbarDelegate {
popover.animates = false
popover.contentSize = NSSize(width: 400, height: 600)
favVC.popover = popover
-
+
self.favoritesPopover = popover
-
+
guard let window = self.window, let contentView = window.contentView else { return }
-
+
// Toolbar items sit above contentView; converting the button rect into contentView coords
// often lands outside bounds, and NSPopover then won't appear. Clamp to the visible top edge.
let b = contentView.bounds
@@ -1244,14 +1298,14 @@ extension WindowController: NSToolbarDelegate {
@objc func taggingAction(_ sender: Any?) {
guard let viewController = contentViewController as? ViewController else { return }
let collectionView = viewController.collectionView!
-
+
let menu = NSMenu()
menu.autoenablesItems = false
-
+
// let isInLargeView = viewController.publicVar.isInLargeView
// let hasSelection = !collectionView.selectionIndexPaths.isEmpty
// let taggingEnabled = isInLargeView || hasSelection
-
+
// let activeTagNames: Set
// let isRatingEnabled: Bool
// if isInLargeView {
@@ -1271,7 +1325,7 @@ extension WindowController: NSToolbarDelegate {
// activeTagNames = []
// isRatingEnabled = false
// }
-
+
// menu.addTaggingMenuItems(
// activeTagNames: activeTagNames,
// target: self,
@@ -1318,9 +1372,9 @@ extension WindowController: NSToolbarDelegate {
clearItem.isEnabled = hasActiveFilter
menu.addItem(NSMenuItem.separator())
-
+
collectionView.buildFilterMenuItems(in: menu)
-
+
if let button = sender as? NSButton {
let menuLocation = NSPoint(x: 0, y: button.bounds.height + 4)
menu.popUp(positioning: nil, at: menuLocation, in: button)
@@ -1329,7 +1383,7 @@ extension WindowController: NSToolbarDelegate {
menu.popUp(positioning: nil, at: menuLocation, in: nil)
}
}
-
+
@objc func actToggleFinderTag(_ sender: NSMenuItem) {
guard let tagName = sender.representedObject as? String else { return }
guard let viewController = contentViewController as? ViewController else { return }
@@ -1360,7 +1414,7 @@ extension WindowController: NSToolbarDelegate {
guard let viewController = contentViewController as? ViewController else {return}
let thumbSizeOptions = THUMB_SIZES.map { ($0, "\($0) × \($0)") }
-
+
let menu = NSMenu()
menu.autoenablesItems = false
@@ -1372,22 +1426,22 @@ extension WindowController: NSToolbarDelegate {
let isGenHdThumb = menu.addItem(withTitle: NSLocalizedString("Always Generate HD Thumbnails", comment: "总是生成高清缩略图"), action: #selector(genHdThumbAction), keyEquivalent: "")
isGenHdThumb.state = (viewController.publicVar.isGenHdThumb) ? .on : .off
-
+
let thumbReadme = menu.addItem(withTitle: NSLocalizedString("Readme...", comment: "说明..."), action: #selector(thumbReadmeAction), keyEquivalent: "")
-
+
menu.addItem(NSMenuItem.separator())
-
+
let enlargeThumb = menu.addItem(withTitle: NSLocalizedString("Enlarge the Thumbnails", comment: "放大缩略图"), action: #selector(enlargeThumb), keyEquivalent: "+")
enlargeThumb.keyEquivalentModifierMask = []
-
+
let reduceThumb = menu.addItem(withTitle: NSLocalizedString("Reduce the Thumbnails", comment: "缩小缩略图"), action: #selector(reduceThumb), keyEquivalent: "-")
reduceThumb.keyEquivalentModifierMask = []
-
+
let defaultThumbSize = menu.addItem(withTitle: NSLocalizedString("Default Thumbnail Size", comment: "默认缩略图大小"), action: #selector(defaultThumbSize), keyEquivalent: "0")
defaultThumbSize.keyEquivalentModifierMask = []
-
+
// menu.addItem(NSMenuItem.separator())
-//
+//
// for (thumbSize, title) in thumbSizeOptions {
// let menuItem = NSMenuItem(title: title, action: #selector(selectThumbSize(_:)), keyEquivalent: "")
// menuItem.target = self
@@ -1400,8 +1454,8 @@ extension WindowController: NSToolbarDelegate {
// }
// menu.addItem(menuItem)
// }
-
-
+
+
if let button = sender as? NSButton {
let buttonFrame = button.convert(button.bounds, to: nil)
let menuLocation = NSPoint(x: 0, y: buttonFrame.height + 4)
@@ -1431,7 +1485,7 @@ extension WindowController: NSToolbarDelegate {
ThumbImageProcessor.clearCache()
viewController.refreshCollectionView([.all], dryRun: true, needLoadThumbPriority: false)
}
-
+
@objc func genHdThumbAction(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.publicVar.isGenHdThumb = true
@@ -1441,35 +1495,88 @@ extension WindowController: NSToolbarDelegate {
ThumbImageProcessor.clearCache()
viewController.refreshCollectionView([.all], dryRun: true, needLoadThumbPriority: false)
}
-
+
@objc func thumbReadmeAction(_ sender: NSMenuItem){
showInformationLong(title: NSLocalizedString("Info", comment: "说明"), message: NSLocalizedString("gen-thumb-info", comment: "对于高清缩略图的说明..."))
}
-
+
@objc func enlargeThumb(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.adjustThumbSizeByDirection(direction: +1)
}
-
+
@objc func defaultThumbSize(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.adjustThumbSizeByDirection(direction: 0)
}
-
+
@objc func reduceThumb(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.adjustThumbSizeByDirection(direction: -1)
}
-
+
@objc func selectThumbSize(_ sender: NSMenuItem) {
guard let thumbSize = sender.representedObject as? Int else { return }
guard let viewController = contentViewController as? ViewController else {return}
viewController.changeThumbSize(thumbSize: thumbSize)
}
-
+
+ @objc func showBatchRotateMenu(_ sender: Any?) {
+ let menu = NSMenu()
+ menu.autoenablesItems = false
+
+ let clockwise90 = menu.addItem(withTitle: NSLocalizedString("Rotate Clockwise 90°", comment: "顺时针旋转90°"), action: #selector(batchRotateSelectedMedia(_:)), keyEquivalent: "")
+ clockwise90.representedObject = BatchMediaRotation.clockwise90.rawValue
+ clockwise90.image = NSImage(systemSymbolName: "rotate.right", accessibilityDescription: "")
+
+ let clockwise180 = menu.addItem(withTitle: NSLocalizedString("Rotate 180°", comment: "旋转180°"), action: #selector(batchRotateSelectedMedia(_:)), keyEquivalent: "")
+ clockwise180.representedObject = BatchMediaRotation.clockwise180.rawValue
+ clockwise180.image = NSImage(systemSymbolName: "arrow.triangle.2.circlepath", accessibilityDescription: "")
+
+ let counterclockwise90 = menu.addItem(withTitle: NSLocalizedString("Rotate Counterclockwise 90°", comment: "逆时针旋转90°"), action: #selector(batchRotateSelectedMedia(_:)), keyEquivalent: "")
+ counterclockwise90.representedObject = BatchMediaRotation.counterclockwise90.rawValue
+ counterclockwise90.image = NSImage(systemSymbolName: "rotate.left", accessibilityDescription: "")
+
+ menu.addItem(NSMenuItem.separator())
+ let restoreVideoRotation = menu.addItem(withTitle: NSLocalizedString("Restore Video Rotation", comment: "还原视频旋转"), action: #selector(batchRotateSelectedMedia(_:)), keyEquivalent: "")
+ restoreVideoRotation.representedObject = BatchMediaRotation.restoreVideo.rawValue
+ restoreVideoRotation.image = NSImage(systemSymbolName: "arrow.uturn.backward.circle", accessibilityDescription: "")
+
+ let enabled = (contentViewController as? ViewController)?.hasSelectedRotatableMedia() ?? false
+ for item in menu.items {
+ item.target = self
+ item.isEnabled = enabled
+ }
+ restoreVideoRotation.isEnabled = (contentViewController as? ViewController)?.hasSelectedVideoMedia() ?? false
+
+ if let button = sender as? NSButton {
+ let menuLocation = NSPoint(x: 0, y: button.bounds.height + 4)
+ menu.popUp(positioning: nil, at: menuLocation, in: button)
+ } else {
+ menu.popUp(positioning: nil, at: NSEvent.mouseLocation, in: nil)
+ }
+ }
+
+ @objc func batchRotateSelectedMedia(_ sender: NSMenuItem) {
+ guard let rawValue = sender.representedObject as? Int,
+ let rotation = BatchMediaRotation(rawValue: rawValue),
+ let viewController = contentViewController as? ViewController else { return }
+ viewController.handleBatchRotateSelectedMedia(rotation)
+ }
+
+ @objc func cropSelectedVideos(_ sender: Any?) {
+ guard let viewController = contentViewController as? ViewController else { return }
+ viewController.handleBatchCropSelectedVideos()
+ }
+
+ @objc func quickRenameAction(_ sender: Any?) {
+ guard let viewController = contentViewController as? ViewController else { return }
+ _ = viewController.handleQuickRenameInCurrentFolder()
+ }
+
@objc func showMoreMenu(_ sender: Any?) {
guard let viewController = contentViewController as? ViewController else {return}
-
+
let menu = NSMenu()
menu.autoenablesItems = false
@@ -1478,48 +1585,58 @@ extension WindowController: NSToolbarDelegate {
if let window = window {
actionItemOntop.state = (window.level == .floating) ? .on : .off
}
-
+
menu.addItem(NSMenuItem.separator())
-
+
let actionItemSettings = menu.addItem(withTitle: NSLocalizedString("Settings...", comment: "设置..."), action: #selector(settingsAction), keyEquivalent: ",")
actionItemSettings.keyEquivalentModifierMask = [.command]
// 文件夹视图
// Folder view
if !viewController.publicVar.isInLargeView {
-
+
menu.addItem(NSMenuItem.separator())
-
+
let customLayoutStyle = menu.addItem(withTitle: NSLocalizedString("Custom Layout Style...", comment: "自定义布局样式..."), action: #selector(customLayoutStyle), keyEquivalent: "")
customLayoutStyle.isEnabled = !viewController.publicVar.isInLargeView
-
+
menu.addItem(NSMenuItem.separator())
-
+
let actionItemShowHiddenFile = menu.addItem(withTitle: NSLocalizedString("Show Hidden Files", comment: "显示隐藏文件"), action: #selector(showHiddenFileAction), keyEquivalent: ".")
actionItemShowHiddenFile.state = (viewController.publicVar.isShowHiddenFile) ? .on : .off
actionItemShowHiddenFile.keyEquivalentModifierMask = [.command, .shift]
-
+
let showAllTypeFile = menu.addItem(withTitle: NSLocalizedString("Show All Types of Files", comment: "显示所有类型文件"), action: #selector(showAllTypeFileAction), keyEquivalent: ",")
showAllTypeFile.state = (viewController.publicVar.isShowAllTypeFile) ? .on : .off
showAllTypeFile.keyEquivalentModifierMask = [.command, .shift]
-
+
let showImageFile = menu.addItem(withTitle: NSLocalizedString("Show Image Files", comment: "显示图像文件"), action: #selector(showImageFileAction), keyEquivalent: "")
showImageFile.state = (viewController.publicVar.isShowImageFile) ? .on : .off
-
+
let showRawFile = menu.addItem(withTitle: NSLocalizedString("Show Camera RAW Files", comment: "显示相机RAW文件"), action: #selector(showRawFileAction), keyEquivalent: "")
showRawFile.state = (viewController.publicVar.isShowRawFile) ? .on : .off
-
+
let showVideoFile = menu.addItem(withTitle: NSLocalizedString("Show Video Files", comment: "显示视频文件"), action: #selector(showVideoFileAction), keyEquivalent: "")
showVideoFile.state = (viewController.publicVar.isShowVideoFile) ? .on : .off
+ let showArchiveFile = menu.addItem(withTitle: NSLocalizedString("显示压缩文件", comment: "显示压缩文件"), action: #selector(showArchiveFileAction), keyEquivalent: "")
+ showArchiveFile.state = globalVar.showArchiveFileType ? .on : .off
+
if viewController.publicVar.isShowAllTypeFile {
showImageFile.isEnabled=false
showRawFile.isEnabled=false
showVideoFile.isEnabled=false
+ showArchiveFile.isEnabled=false
}
}
+ if !viewController.publicVar.isInLargeView {
+ menu.addItem(NSMenuItem.separator())
+ let goParentFolder = menu.addItem(withTitle: NSLocalizedString("返回上一级目录", comment: "返回上一级目录"), action: #selector(goParentFolderAction), keyEquivalent: String(Character(UnicodeScalar(NSUpArrowFunctionKey)!)))
+ goParentFolder.keyEquivalentModifierMask = [.command]
+ }
+
if viewController.publicVar.isInLargeView {
menu.addItem(NSMenuItem.separator())
@@ -1555,7 +1672,7 @@ extension WindowController: NSToolbarDelegate {
useInternalPlayer.isEnabled = !viewController.publicVar.isInLargeView
let videoPlayInfo = menu.addItem(withTitle: NSLocalizedString("Readme...", comment: "说明..."), action: #selector(videoPlayInfo), keyEquivalent: "")
-
+
}
if (viewController.publicVar.isInLargeView && viewController.largeImageView.file.type == .video) {
@@ -1575,7 +1692,7 @@ extension WindowController: NSToolbarDelegate {
} else {
actionItemABPlay.state = .off
}
-
+
let actionItemSequentialPlay = menu.addItem(withTitle: NSLocalizedString("Sequential Playback", comment: "(视频)顺序播放"), action: #selector(actSequentialPlay), keyEquivalent: "l")
actionItemSequentialPlay.keyEquivalentModifierMask = []
actionItemSequentialPlay.state = globalVar.videoPlaySequentialPlay ? .on : .off
@@ -1596,19 +1713,19 @@ extension WindowController: NSToolbarDelegate {
let recursiveContainFolder = menu.addItem(withTitle: NSLocalizedString("Include Folders", comment: "包含文件夹"), action: #selector(toggleRecursiveContainFolder), keyEquivalent: "f")
recursiveContainFolder.keyEquivalentModifierMask = [.command, .shift]
recursiveContainFolder.state = (viewController.publicVar.isRecursiveContainFolder) ? .on : .off
-
+
let recursiveModeInfo = menu.addItem(withTitle: NSLocalizedString("Readme...", comment: "说明..."), action: #selector(recursiveModeInfo), keyEquivalent: "")
-
+
// 大图视图
// Large image view
} else {
menu.addItem(NSMenuItem.separator())
-
+
let lockRotation = menu.addItem(withTitle: NSLocalizedString("Lock Rotation", comment: "锁定旋转"), action: #selector(toggleLockRotation), keyEquivalent: "")
lockRotation.keyEquivalentModifierMask = []
lockRotation.state = viewController.publicVar.isRotationLocked ? .on : .off
-
+
let lockZoom = menu.addItem(withTitle: NSLocalizedString("Lock Zoom", comment: "锁定缩放"), action: #selector(toggleLockZoom), keyEquivalent: "")
lockZoom.keyEquivalentModifierMask = []
lockZoom.state = viewController.publicVar.isZoomLocked ? .on : .off
@@ -1622,16 +1739,16 @@ extension WindowController: NSToolbarDelegate {
if viewController.largeImageView.file.type == .image {
menu.addItem(NSMenuItem.separator())
-
+
let panWhenZoomed = menu.addItem(withTitle: NSLocalizedString("pan-zoom", comment: "(放大后滚动变为平移)"), action: #selector(togglePanWhenZoomed), keyEquivalent: "")
panWhenZoomed.keyEquivalentModifierMask = []
panWhenZoomed.state = viewController.publicVar.isPanWhenZoomed ? .on : .off
-
+
let panZoomInfo = menu.addItem(withTitle: NSLocalizedString("Readme...", comment: "说明..."), action: #selector(panZoomInfo), keyEquivalent: "")
-
+
// let customZoomRatio = menu.addItem(withTitle: NSLocalizedString("Custom Zoom Ratio...", comment: "自定义缩放比例..."), action: #selector(showCustomZoomRatioDialog), keyEquivalent: "")
// customZoomRatio.keyEquivalentModifierMask = []
-
+
// let customZoomStep = menu.addItem(withTitle: NSLocalizedString("Custom Zoom Step...", comment: "自定义缩放梯度..."), action: #selector(showCustomZoomStepDialog), keyEquivalent: "")
// customZoomStep.keyEquivalentModifierMask = []
@@ -1646,15 +1763,15 @@ extension WindowController: NSToolbarDelegate {
}
}
-
+
// menu.addItem(NSMenuItem.separator())
-
+
// let portableMode = menu.addItem(withTitle: NSLocalizedString("Portable Browsing Mode", comment: "便携浏览模式"), action: #selector(togglePortableMode), keyEquivalent: "")
// portableMode.keyEquivalentModifierMask = []
// portableMode.state = globalVar.portableMode ? .on : .off
-
+
// let portableModeInfo = menu.addItem(withTitle: NSLocalizedString("Readme...", comment: "说明..."), action: #selector(portableModeInfo), keyEquivalent: "")
-
+
menu.addItem(NSMenuItem.separator())
var autoScrollMenuText = NSLocalizedString("Enable Automatic Scroll", comment: "启用自动滚动")
@@ -1663,7 +1780,7 @@ extension WindowController: NSToolbarDelegate {
}
let autoScroll = menu.addItem(withTitle: autoScrollMenuText, action: #selector(toggleAutoScroll), keyEquivalent: "")
autoScroll.isEnabled = !viewController.publicVar.isInLargeView
-
+
var autoPlayMenuText = NSLocalizedString("Enable Automatic Play", comment: "启用自动播放")
if viewController.autoPlayTimer != nil {
autoPlayMenuText = NSLocalizedString("Disable Automatic Play", comment: "停止自动播放")
@@ -1672,39 +1789,39 @@ extension WindowController: NSToolbarDelegate {
autoPlay.isEnabled = viewController.publicVar.isInLargeView && viewController.largeImageView.file.type == .image
menu.addItem(NSMenuItem.separator())
-
+
let maximizeWindow = menu.addItem(withTitle: NSLocalizedString("Maximize Window", comment: "最大化窗口"), action: #selector(maximizeWindow), keyEquivalent: "1")
maximizeWindow.keyEquivalentModifierMask = []
-
+
let optimizeWindow = menu.addItem(withTitle: NSLocalizedString("optimizeWindow", comment: "合适窗口大小"), action: #selector(optimizeWindow), keyEquivalent: "2")
optimizeWindow.keyEquivalentModifierMask = []
-
+
let adjustWindowActual = menu.addItem(withTitle: NSLocalizedString("Adjust Window to Actual Image Size", comment: "调整窗口至图片实际大小"), action: #selector(adjustWindowActual), keyEquivalent: "3")
adjustWindowActual.keyEquivalentModifierMask = []
-
+
let adjustWindowCurrent = menu.addItem(withTitle: NSLocalizedString("Adjust Window to Current Image Size", comment: "调整窗口至图片当前大小"), action: #selector(adjustWindowCurrent), keyEquivalent: "4")
adjustWindowCurrent.keyEquivalentModifierMask = []
-
+
let adjustWindowToCenter = menu.addItem(withTitle: NSLocalizedString("Center the Window", comment: "将窗口居中"), action: #selector(adjustWindowToCenter), keyEquivalent: "5")
adjustWindowToCenter.keyEquivalentModifierMask = []
-
+
adjustWindowActual.isEnabled = (viewController.publicVar.isInLargeView)
adjustWindowCurrent.isEnabled = (viewController.publicVar.isInLargeView)
-
+
if viewController.publicVar.isInLargeView {
-
+
menu.addItem(NSMenuItem.separator())
-
+
let switchToActualSize = menu.addItem(withTitle: NSLocalizedString("switchToActualSize", comment: "图片默认实际大小"), action: #selector(switchToActualSize), keyEquivalent: "")
-
+
let switchToFitToWindow = menu.addItem(withTitle: NSLocalizedString("switchToFitToWindow", comment: "图片默认适应窗口"), action: #selector(switchToFitToWindow), keyEquivalent: "")
-
+
switchToActualSize.state = (viewController.publicVar.isLargeImageFitWindow == false) ? .on : .off
switchToFitToWindow.state = (viewController.publicVar.isLargeImageFitWindow == true) ? .on : .off
}
menu.addItem(NSMenuItem.separator())
-
+
let switchToSystemTheme = menu.addItem(withTitle: NSLocalizedString("switchToSystemTheme", comment: "跟随系统主题"), action: #selector(switchToSystemTheme), keyEquivalent: "")
let switchToLightMode = menu.addItem(withTitle: NSLocalizedString("switchToLightMode", comment: "浅色模式"), action: #selector(switchToLightMode), keyEquivalent: "")
let switchToDarkMode = menu.addItem(withTitle: NSLocalizedString("switchToDarkMode", comment: "黑暗模式"), action: #selector(switchToDarkMode), keyEquivalent: "")
@@ -1718,7 +1835,7 @@ extension WindowController: NSToolbarDelegate {
switchToLightMode.state = (theme == .darkAqua) ? .off : .on
switchToDarkMode.state = (theme == .darkAqua) ? .on : .off
}
-
+
if let button = sender as? NSButton {
let buttonFrame = button.convert(button.bounds, to: nil)
let menuLocation = NSPoint(x: 0, y: buttonFrame.height + 4)
@@ -1762,21 +1879,21 @@ extension WindowController: NSToolbarDelegate {
guard let viewController = contentViewController as? ViewController else {return}
viewController.showCustomZoomRatioDialog()
}
-
+
@objc func showCustomZoomStepDialog(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.showCustomZoomStepDialog()
}
-
+
@objc func panZoomInfo(_ sender: NSMenuItem){
showInformationLong(title: NSLocalizedString("Info", comment: "说明"), message: NSLocalizedString("pan-zoom-info", comment: "对于缩放后平移的说明..."), width: 300)
}
-
+
@objc func togglePanWhenZoomed(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.togglePanWhenZoomed()
}
-
+
@objc func toggleRawUseEmbeddedThumb(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.toggleRawUseEmbeddedThumb()
@@ -1785,60 +1902,60 @@ extension WindowController: NSToolbarDelegate {
@objc func rawUseEmbeddedThumbInfo(_ sender: NSMenuItem){
showInformationLong(title: NSLocalizedString("Info", comment: "说明"), message: NSLocalizedString("raw-use-embeded-info", comment: "raw使用exif内嵌缩略图替代浏览的说明..."), width: 300)
}
-
+
@objc func maximizeWindow(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.adjustWindowMaximize()
}
-
+
@objc func optimizeWindow(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.adjustWindowSuitable()
}
-
+
@objc func adjustWindowActual(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.adjustWindowImageActual()
}
-
+
@objc func adjustWindowCurrent(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.adjustWindowImageCurrent()
}
-
+
@objc func adjustWindowToCenter(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.adjustWindowToCenter()
}
-
+
@objc func switchToActualSize(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.switchToActualSizeForLargeImage()
}
-
+
@objc func switchToFitToWindow(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.switchToFitToWindowForLargeImage()
}
-
+
@objc func switchToSystemTheme(_ sender: NSMenuItem){
let defaults = UserDefaults.standard
defaults.set("", forKey: "appearance")
NSApp.appearance=nil
}
-
+
@objc func switchToLightMode(_ sender: NSMenuItem){
let defaults = UserDefaults.standard
defaults.set("aqua", forKey: "appearance")
NSApp.appearance=NSAppearance(named: .aqua)
}
-
+
@objc func switchToDarkMode(_ sender: NSMenuItem){
let defaults = UserDefaults.standard
defaults.set("darkAqua", forKey: "appearance")
NSApp.appearance=NSAppearance(named: .darkAqua)
}
-
+
@objc func pathClick(_ sender: NSMenuItem) {
guard let viewController = contentViewController as? ViewController else {return}
log("Clicked on \(sender.title)")
@@ -1850,42 +1967,27 @@ extension WindowController: NSToolbarDelegate {
}
viewController.switchDirByDirection(direction: .zero, dest: url.absoluteString, doCollapse: true, expandLast: true, skip: false, stackDeep: 0)
}
-
+
@objc func favoritesAdd(_ sender: NSMenuItem) {
guard let viewController = contentViewController as? ViewController else {return}
viewController.fileDB.lock()
let curFolder=viewController.fileDB.curFolder
viewController.fileDB.unlock()
- if !globalVar.myFavoritesArray.contains(curFolder) {
- globalVar.myFavoritesArray.append(curFolder)
- let defaults = UserDefaults.standard
- defaults.set(globalVar.myFavoritesArray, forKey: "globalVar.myFavoritesArray")
- }
+ _ = addFavoritePath(curFolder)
}
@objc func deleteFavorite(_ sender: NSMenuItem) {
guard let folderPath = sender.representedObject as? String else { return }
-
- // 在这里处理删除逻辑
- // Handle delete logic here
- if let index = globalVar.myFavoritesArray.firstIndex(of: folderPath) {
- globalVar.myFavoritesArray.remove(at: index)
- let defaults = UserDefaults.standard
- defaults.set(globalVar.myFavoritesArray, forKey: "globalVar.myFavoritesArray")
- }
-
- // 更新菜单以反映更改
- // Update menu to reflect changes
- // menuNeedsUpdate(favoritesMenu)
+ _ = removeFavoritePath(folderPath)
}
@objc func moveUpFavorite(_ sender: NSMenuItem) {
guard let index = sender.representedObject as? Int, index > 0 else { return }
-
+
// 在这里处理上移逻辑
// Handle move up logic here
globalVar.myFavoritesArray.swapAt(index, index - 1)
let defaults = UserDefaults.standard
defaults.set(globalVar.myFavoritesArray, forKey: "globalVar.myFavoritesArray")
-
+
// 更新菜单以反映更改
// Update menu to reflect changes
// menuNeedsUpdate(favoritesMenu)
@@ -1893,57 +1995,67 @@ extension WindowController: NSToolbarDelegate {
@objc func moveDownFavorite(_ sender: NSMenuItem) {
guard let index = sender.representedObject as? Int, index < globalVar.myFavoritesArray.count - 1 else { return }
-
+
// 在这里处理下移逻辑
// Handle move down logic here
globalVar.myFavoritesArray.swapAt(index, index + 1)
let defaults = UserDefaults.standard
defaults.set(globalVar.myFavoritesArray, forKey: "globalVar.myFavoritesArray")
-
+
// 更新菜单以反映更改
// menuNeedsUpdate(favoritesMenu)
}
-
+
@objc func settingsAction(_ sender: NSMenuItem) {
if let appDelegate = NSApplication.shared.delegate as? AppDelegate {
appDelegate.settingsWindowController.show()
}
}
-
+
@objc func showHiddenFileAction(_ sender: NSMenuItem) {
guard let viewController = contentViewController as? ViewController else {return}
viewController.toggleIsShowHiddenFile()
}
-
+
@objc func showAllTypeFileAction(_ sender: NSMenuItem) {
guard let viewController = contentViewController as? ViewController else {return}
viewController.toggleIsShowAllTypeFile()
}
-
+
@objc func showImageFileAction(_ sender: NSMenuItem) {
guard let viewController = contentViewController as? ViewController else {return}
viewController.toggleIsShowImageFile()
}
-
+
@objc func showRawFileAction(_ sender: NSMenuItem) {
guard let viewController = contentViewController as? ViewController else {return}
viewController.toggleIsShowRawFile()
}
-
+
@objc func showVideoFileAction(_ sender: NSMenuItem) {
guard let viewController = contentViewController as? ViewController else {return}
viewController.toggleIsShowVideoFile()
}
-
+
+ @objc func showArchiveFileAction(_ sender: NSMenuItem) {
+ guard let viewController = contentViewController as? ViewController else { return }
+ viewController.toggleShowArchiveFileType()
+ }
+
+ @objc func goParentFolderAction(_ sender: NSMenuItem) {
+ guard let viewController = contentViewController as? ViewController else { return }
+ viewController.switchDirByDirection(direction: .up, stackDeep: 0)
+ }
+
@objc func togglePortableMode(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.togglePortableMode()
}
-
+
@objc func portableModeInfo(_ sender: NSMenuItem){
showInformationLong(title: NSLocalizedString("Info", comment: "说明"), message: NSLocalizedString("portable-mode-info", comment: "对于便携模式的说明..."), width: 300)
}
-
+
@objc func toggleSearchFilter(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.applyFilter(isReset: true)
@@ -1963,31 +2075,31 @@ extension WindowController: NSToolbarDelegate {
guard let viewController = contentViewController as? ViewController else { return }
viewController.handleClearTagsAndRatingFilter()
}
-
+
@objc func toggleRecursiveMode(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.toggleRecursiveMode()
}
-
+
@objc func toggleRecursiveContainFolder(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.toggleRecursiveContainFolder()
}
-
+
@objc func recursiveModeInfo(_ sender: NSMenuItem){
showInformationLong(title: NSLocalizedString("Info", comment: "说明"), message: NSLocalizedString("recursive-mode-info", comment: "对于递归模式的说明..."), width: 300)
}
-
+
@objc func toggleAutoScroll(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.toggleAutoScroll()
}
-
+
@objc func toggleAutoPlay(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.toggleAutoPlay()
}
-
+
@objc func toggleAutoPlayVisibleVideo(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.toggleAutoPlayVisibleVideo()
@@ -2002,7 +2114,7 @@ extension WindowController: NSToolbarDelegate {
guard let viewController = contentViewController as? ViewController else {return}
viewController.toggleUseInternalPlayer()
}
-
+
@objc func videoPlayInfo(_ sender: NSMenuItem){
showInformationLong(title: NSLocalizedString("Info", comment: "说明"), message: NSLocalizedString("video-play-info", comment: "对于视频播放的说明..."))
}
@@ -2022,7 +2134,7 @@ extension WindowController: NSToolbarDelegate {
guard let viewController = contentViewController as? ViewController else {return}
viewController.largeImageView.actABPlay()
}
-
+
@objc func actSequentialPlay(_ sender: NSMenuItem){
guard let viewController = contentViewController as? ViewController else {return}
viewController.largeImageView.actSequentialPlay()
diff --git a/README.md b/README.md
index b5f6a5e6..f62b5f0d 100644
--- a/README.md
+++ b/README.md
@@ -1,39 +1,83 @@
-
FlowVision
-Waterfall-style Image Viewer for macOS[中文说明]
+Waterfall-style Image Viewer for macOS[中文说明]
[](https://github.com/netdcy/FlowVision/releases/latest?color=blue "GitHub release") 
## Screenshots
+
+
### Light Mode

### Dark Mode

-## Features:
- - Adaptive layout mode, light/dark mode
- - Convenient file management (similar to Finder)
- - Right-click gestures, quickly find the previous/next folder with images/videos
- - Performance optimizations for directories with a large number of images
- - High-quality scaling (reduces moiré and other issues)
- - Support for video playback
- - Support for HDR display
- - Recursive mode
+## Features
+
+### Core Features
+- Adaptive layout modes (Justified, Waterfall, Grid, Detail)
+- Light/Dark mode support
+- Convenient file management (similar to Finder)
+- Right-click gestures for quick folder navigation
+- Performance optimizations for directories with large number of images
+- High-quality scaling (reduces moiré and other issues)
+- HDR display support
+- Recursive browsing mode
+
+### Image Features
+- Support for 40+ image formats including RAW files
+- Double-click to open/close large image view
+- Mouse gesture zoom (hold right/left button + scroll wheel)
+- Long press left button for 100% zoom
+- Long press right button to fit image to view
+- Image rotation and mirror flip
+- OCR text recognition
+- QR code detection
+- EXIF information display
+- Image editing mode
+
+### Video Features
+- Built-in video player with FFmpeg support
+- Video seek with arrow keys
+- A-B loop playback (set points with `,` and `.` keys)
+- Remember playback position
+- Sequential playback mode
+- Video frame capture
+- Auto-play visible videos option
+
+### File Management
+- Copy/Move/Delete/Rename operations
+- Quick search by filename (supports pinyin)
+- Quick rename with custom rules
+- Custom shortcuts for copying to specified folders
+- Finder tags and ratings support
+- Archive file support with image extraction
+- New folder creation
+
+### Layout & Profiles
+- Multiple layout types switchable
+- 9 customizable profile slots
+- Thumbnail size adjustment
+- Sort by various criteria (name, date, size, EXIF, random)
## Installation and Usage
### System Requirements
- - macOS 11.0 or Later
+- macOS 11.0 or Later
### Privacy and Security
- - Open source
- - No Internet connection
+- Open source
+- Local browsing and playback do not require an Internet connection
+- FlowVision sends a `HEAD` request to its GitHub Releases page when checking for updates; update downloads start only after confirmation
+
+### Updates
+
+Choose **FlowVision → Check for Updates…** to check GitHub Releases. FlowVision also performs a silent check shortly after launch. When a newer version is available, it can download the fixed `FlowVision-macOS.zip` release asset and install it with the bundled rollback-capable updater. Installations in non-writable or package-manager-owned locations must be upgraded with their original installer.
### Homebrew Install
@@ -47,23 +91,129 @@ brew update
brew upgrade flowvision
```
-## Instructions:
-### In Image View:
- - Double-click to open/close the image
- - Hold down the right/left mouse button and scroll the wheel to zoom
- - Hold down the middle mouse button and drag to move the window
- - Long press the left mouse button to switch to 100% zoom
- - Long press the right mouse button to fit the image to the view
-### Right-Click Gestures:
- - Right/Left: Switch to the next/previous folder with images/videos (logically equivalent to the next folder when sorting all folders on the disk)
- - Up: Switch to the parent directory
- - Down: Return to the previous directory
- - Up-Right: Switch to the next folder with images at the same level as the current folder
- - Down-Right: Close the tab/window
-### Keyboard Shortcuts:
- - W: Same as the right-click gesture Up
- - A/D: Same as the right-click gesture Left/Right
- - S: Same as the right-click gesture Down
+## Keyboard Shortcuts
+
+### Navigation
+| Key | Action |
+|-----|--------|
+| `W` | Go to parent directory (or zoom in large view) |
+| `A` | Previous folder/image (or zoom out in large view) |
+| `D` | Next folder/image |
+| `S` | Return to previous directory (or zoom out in large view) |
+| `Q` | Quick search / Rotate left |
+| `E` | Rotate right / Close tab |
+| `Space` | Open/close image or play/pause video |
+| `Enter` | Open image (if enabled in settings) or rename |
+| `Esc` | Close large view / Deselect all |
+| `Tab` | Switch focus between sidebar and thumbnail view |
+
+### Arrow Keys
+| Key | Action |
+|-----|--------|
+| `←/→/↑/↓` | Navigate images or folders |
+| `Cmd+↑` | Go to parent directory |
+| `Cmd+↓` | Enter selected folder |
+| `Cmd+←/→` | Previous/next image (or video frame seek) |
+| `Shift+←/→` | Previous/next file (for video) |
+| `Opt+↑/↓` | Page up/down |
+
+### File Operations
+| Key | Action |
+|-----|--------|
+| `R` / `F2` | Rename |
+| `Delete` | Move to trash |
+| `Cmd+Z` | Undo |
+| `Cmd+Shift+Z` | Redo |
+| `Cmd+R` / `F5` | Refresh |
+| `Cmd+Shift+N` | New folder |
+| `Cmd+Shift+V` | Toggle auto-play visible videos |
+
+### Image/Video Specific
+| Key | Action |
+|-----|--------|
+| `Z` | Zoom to 100% |
+| `X` | Zoom to fit |
+| `I` | Show EXIF info |
+| `U` | Show file info |
+| `O` | OCR text recognition |
+| `P` | QR code detection |
+| `,` | Set video A-B loop point A |
+| `.` | Set video A-B loop point B |
+| `J` | Remember video playback position |
+| `K` | Toggle A-B loop playback |
+| `L` | Toggle sequential playback |
+| `Cmd+E` | Capture video frame |
+| `Cmd+Shift+E` | Enter edit mode |
+
+### Tags and Ratings
+| Key | Action |
+|-----|--------|
+| `Cmd+1~9` | Toggle Finder tag (1-9) |
+| `Ctrl+0~5` | Set rating (0-5 stars) |
+
+### Profiles and Layout
+| Key | Action |
+|-----|--------|
+| `Opt+1~9` | Switch to profile 1-9 |
+| `Cmd+Opt+1~9` | Save current settings to profile 1-9 |
+| `Cmd+Shift+R` | Toggle recursive mode |
+| `Cmd+Shift+F` | Toggle recursive folder containment |
+| `Cmd+Shift+T` | Reopen closed tab |
+| `F3` | Open search |
+
+### Window Control
+| Key | Action |
+|-----|--------|
+| `1` | Maximize window |
+| `2` | Fit window size |
+| `3` | Resize window to image actual size |
+| `4` | Resize window to image current size |
+| `5` | Center window |
+| `=` / `-` | Increase/decrease thumbnail size |
+| `0` | Reset thumbnail size |
+| `Opt+Enter` | Toggle fullscreen |
+| `T` | Pin window to top |
+
+### Custom Shortcuts
+- Configurable shortcuts for copying files to specified folders
+- Quick rename rule templates (e.g., `{folder}_{index}`)
+
+## Right-Click Gestures
+
+| Gesture | Action |
+|---------|--------|
+| Right | Next folder with images/videos |
+| Left | Previous folder with images/videos |
+| Up | Parent directory |
+| Down | Return to previous directory |
+| Up-Right | Next folder at same level |
+| Down-Right | Close tab/window |
+
+## Mouse Operations in Large View
+
+| Operation | Action |
+|-----------|--------|
+| Double-click | Open/close image |
+| Hold right/left + scroll | Zoom |
+| Hold middle + drag | Move window |
+| Long press left | 100% zoom |
+| Long press right | Fit to view |
+
+## Supported Formats
+
+### Images
+**Standard:** jpg, jpeg, png, gif, bmp, webp, tiff, ico, svg, jfif
+
+**High Quality:** heif, heic, hif, avif, jxl, jp2
+
+**RAW:** crw, cr2, cr3, nef, nrw, arw, srf, sr2, rw2, orf, raf, pef, dng, raw, rwl, x3f, 3fr, fff, iiq, mos, dcr, erf, mrw, gpr, srw
+
+**Design:** ai, psd
+
+### Videos
+**Native:** mp4, mov, m2ts, ts, mpeg, mpg, m4v, vob
+
+**FFmpeg:** mkv, mts, avi, flv, f4v, asf, wmv, rmvb, rm, webm, divx, xvid, 3gp, 3g2
## Build
@@ -80,12 +230,12 @@ Xcode 15.2+
### Steps
1. Clone the source code of the project and libraries.
-2. For ffmpeg-kit, it need to be built to binary first. If you want to save time, you can directly download its pre-built binary, named like `ffmpeg-kit-full-gpl-6.0-macos-xcframework.zip` (not LTS version). Unzip it, then execute this in terminal to remove its quarantine attribute:
+2. For ffmpeg-kit, it needs to be built to binary first. If you want to save time, you can directly download its pre-built binary, named like `ffmpeg-kit-full-gpl-6.0-macos-xcframework.zip` (not LTS version). Unzip it, then execute this in terminal to remove its quarantine attribute:
```
sudo xattr -rd com.apple.quarantine ./ffmpeg-kit-full-gpl-6.0-macos-xcframework
```
-
+
(Due to the project being discontinued and copyright reasons, the prebuilt binaries have been removed. Here is a [backup](https://github.com/netdcy/ffmpeg-kit/releases/download/v6.0/ffmpeg-kit-full-gpl-6.0-macos-xcframework.zip) of original file.)
3. Organize the directory structure as shown below:
@@ -108,15 +258,14 @@ Xcode 15.2+
```
4. Open `FlowVision.xcodeproj` by Xcode, click 'Product' -> 'Build For' -> 'Profiling' in menu bar.
-5. Then 'Product' -> 'Show Build Folder in Finder', and you will find the app is at `Products/Release/FlowVision.app`.
+5. Then 'Product' -> 'Show Build Folder in Finder', and you will find the app at `Products/Release/FlowVision.app`.
## Donate
-If you found the project is helpful, feel free to buy me a coffee.
+If you found the project helpful, feel free to buy me a coffee.
[](https://buymeacoffee.com/netdcyn)
## License
This project is licensed under the GPL License. See the [LICENSE](https://github.com/netdcy/FlowVision/blob/main/LICENSE) file for the full license text.
-
diff --git a/README_zh.md b/README_zh.md
index 5ada75d7..b2f4571b 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -1,6 +1,6 @@
FlowVision
-为macOS设计的瀑布流式图片浏览器
+为 macOS 设计的瀑布流式图片浏览器
[](https://github.com/netdcy/FlowVision/releases/latest?color=blue "GitHub release") 
@@ -10,37 +10,72 @@
### 浅色模式

-### 黑暗模式
+### 深色模式

-## 应用特点:
-
- - 自适应布局模式、浅色/深色模式
-
- - 方便的文件管理(操作类似 Finder)
-
- - 右键手势、快速查找上一个/下一个有图片/视频的文件夹
-
- - 针对目录下大量图片情况的性能优化
-
- - 高质量的缩放(减轻摩尔纹等问题)
-
- - 支持视频播放
-
- - 支持HDR显示
-
- - 支持递归模式
+## 功能特性
+
+### 核心功能
+- 多种自适应布局模式(两端对齐、瀑布流、网格、详情列表)
+- 浅色/深色模式自动适配
+- 便捷的文件管理(操作类似 Finder)
+- 右键手势快速文件夹导航
+- 大量图片目录的性能优化
+- 高质量缩放(减轻摩尔纹等问题)
+- HDR 显示支持
+- 递归浏览模式
+
+### 图片功能
+- 支持 40+ 种图片格式,包括 RAW 文件
+- 双击打开/关闭大图查看
+- 鼠标手势缩放(按住右键/左键 + 滚轮)
+- 长按左键切换 100% 缩放
+- 按右键切换适应视图
+- 图片旋转和镜像翻转
+- OCR 文字识别
+- 二维码检测
+- EXIF 信息显示
+- 图片编辑模式
+
+### 视频功能
+- 内置视频播放器(FFmpeg 支持)
+- 方向键视频定位
+- A-B 循环播放(用 `,` 和 `.` 设置循环点)
+- 记忆播放位置
+- 顺序播放模式
+- 视频截图
+- 自动播放可见视频选项
+
+### 文件管理
+- 复制/移动/删除/重命名操作
+- 快速搜索文件名(支持拼音搜索)
+- 自定义规则快速重命名
+- 自定义快捷键复制到指定文件夹
+- Finder 标签和评分支持
+- 压缩包文件支持,可提取内部图片
+- 新建文件夹
+
+### 布局与配置
+- 多种布局类型可切换
+- 9 个可自定义配置槽位
+- 缩略图大小调节
+- 多种排序方式(名称、日期、大小、EXIF、随机等)
## 安装使用
### 系统需求
- - macOS 11.0+
+- macOS 11.0+
### 隐私与安全性
- - 开源软件
- - 无网络请求
+- 开源软件
+- 本地浏览和播放不需要网络连接
+- 检查更新时会向 GitHub Releases 发送 `HEAD` 请求;仅在用户确认后下载更新包
+
+### 检查更新
+
+选择 **FlowVision → 检查更新…** 可检查 GitHub Releases;应用启动后也会静默检查一次。发现新版本后,可下载固定名称的 `FlowVision-macOS.zip` 并通过内置、支持失败回滚的更新助手完成安装。若安装目录不可写或由包管理器管理,请使用原安装工具升级。
### Homebrew 方式安装
@@ -54,24 +89,129 @@ brew update
brew upgrade flowvision
```
-## 操作说明
-
-### 图片浏览:
- - 双击打开/关闭图片
- - 按住右键/左键滚动滚轮可以缩放
- - 按住中键拖动可以移动窗口
- - 长按左键切换 100%缩放
- - 长按右键切换缩放到视图
-### 右键手势:
- - 向右/左:切换到下一个/上一个有图片/视频的文件夹(逻辑上等同于将整个磁盘中的文件夹排序后的下一个)
- - 向上:切换到上级目录
- - 向下:返回到上一次的目录
- - 向上右:切换到与当前文件夹平级的下一个有图片的文件夹
- - 向下右:关闭当前标签页/窗口
-### 键盘按键:
- - W:同右键手势 向上
- - A/D:同右键手势 向左/右
- - S:同右键手势 向下
+## 键盘快捷键
+
+### 导航
+| 按键 | 功能 |
+|-----|------|
+| `W` | 上级目录(大图模式:放大) |
+| `A` | 上一个文件夹/图片(大图模式:缩小) |
+| `D` | 下一个文件夹/图片 |
+| `S` | 返回上次目录(大图模式:缩小) |
+| `Q` | 快速搜索 / 左旋 |
+| `E` | 右旋 / 关闭标签页 |
+| `Space` | 打开/关闭图片或播放/暂停视频 |
+| `Enter` | 打开图片(可在设置中启用)或重命名 |
+| `Esc` | 关闭大图 / 取消选择 |
+| `Tab` | 在侧栏和缩略图视图间切换焦点 |
+
+### 方向键
+| 按键 | 功能 |
+|-----|------|
+| `←/→/↑/↓` | 导航图片或文件夹 |
+| `Cmd+↑` | 进入上级目录 |
+| `Cmd+↓` | 进入选中的文件夹 |
+| `Cmd+←/→` | 上/下一张图片(视频:逐帧定位) |
+| `Shift+←/→` | 上/下一个文件(视频模式) |
+| `Opt+↑/↓` | 翻页 |
+
+### 文件操作
+| 按键 | 功能 |
+|-----|------|
+| `R` / `F2` | 重命名 |
+| `Delete` | 移到废纸篓 |
+| `Cmd+Z` | 撤销 |
+| `Cmd+Shift+Z` | 重做 |
+| `Cmd+R` / `F5` | 刷新 |
+| `Cmd+Shift+N` | 新建文件夹 |
+| `Cmd+Shift+V` | 切换自动播放可见视频 |
+
+### 图片/视频专用
+| 按键 | 功能 |
+|-----|------|
+| `Z` | 缩放到 100% |
+| `X` | 缩放适合 |
+| `I` | 显示 EXIF 信息 |
+| `U` | 显示文件信息 |
+| `O` | OCR 文字识别 |
+| `P` | 二维码检测 |
+| `,` | 设置视频 A-B 循环点 A |
+| `.` | 设置视频 A-B 循环点 B |
+| `J` | 记忆视频播放位置 |
+| `K` | 切换 A-B 循环播放 |
+| `L` | 切换顺序播放 |
+| `Cmd+E` | 视频截图 |
+| `Cmd+Shift+E` | 进入编辑模式 |
+
+### 标签和评分
+| 按键 | 功能 |
+|-----|------|
+| `Cmd+1~9` | 切换 Finder 标签 (1-9) |
+| `Ctrl+0~5` | 设置评分 (0-5 星) |
+
+### 配置和布局
+| 按键 | 功能 |
+|-----|------|
+| `Opt+1~9` | 切换到配置 1-9 |
+| `Cmd+Opt+1~9` | 保存当前设置到配置 1-9 |
+| `Cmd+Shift+R` | 切换递归模式 |
+| `Cmd+Shift+F` | 切换递归包含文件夹 |
+| `Cmd+Shift+T` | 重新打开已关闭的标签页 |
+| `F3` | 打开搜索 |
+
+### 窗口控制
+| 按键 | 功能 |
+|-----|------|
+| `1` | 最大化窗口 |
+| `2` | 合适窗口大小 |
+| `3` | 调整窗口至图片实际大小 |
+| `4` | 调整窗口至图片当前大小 |
+| `5` | 窗口居中 |
+| `=` / `-` | 增大/减小缩略图大小 |
+| `0` | 重置缩略图大小 |
+| `Opt+Enter` | 切换全屏 |
+| `T` | 窗口置顶 |
+
+### 自定义快捷键
+- 可配置快捷键将文件复制到指定文件夹
+- 快速重命名规则模板(如 `{folder}_{index}`)
+
+## 右键手势
+
+| 手势 | 功能 |
+|-----|------|
+| 向右 | 下一个有图片/视频的文件夹 |
+| 向左 | 上一个有图片/视频的文件夹 |
+| 向上 | 上级目录 |
+| 向下 | 返回上次目录 |
+| 向上右 | 同级下一个有图片的文件夹 |
+| 向下右 | 关闭标签页/窗口 |
+
+## 大图查看鼠标操作
+
+| 操作 | 功能 |
+|-----|------|
+| 双击 | 打开/关闭图片 |
+| 按住右键/左键 + 滚轮 | 缩放 |
+| 按住中键 + 拖动 | 移动窗口 |
+| 长按左键 | 100% 缩放 |
+| 按右键 | 适应视图 |
+
+## 支持的格式
+
+### 图片
+**常见格式:** jpg, jpeg, png, gif, bmp, webp, tiff, ico, svg, jfif
+
+**高质量格式:** heif, heic, hif, avif, jxl, jp2
+
+**RAW 格式:** crw, cr2, cr3, nef, nrw, arw, srf, sr2, rw2, orf, raf, pef, dng, raw, rwl, x3f, 3fr, fff, iiq, mos, dcr, erf, mrw, gpr, srw
+
+**设计文件:** ai, psd
+
+### 视频
+**原生支持:** mp4, mov, m2ts, ts, mpeg, mpg, m4v, vob
+
+**FFmpeg 支持:** mkv, mts, avi, flv, f4v, asf, wmv, rmvb, rm, webm, divx, xvid, 3gp, 3g2
## 编译
@@ -88,13 +228,13 @@ Xcode 15.2+
### 构建步骤
1. 克隆此项目和依赖库的代码。
-2. 对于ffmpeg-kit,需要预先构建二进制文件。如果你想省时间,可以直接下载它已构建好的二进制库,例如 `ffmpeg-kit-full-gpl-6.0-macos-xcframework.zip` (非LTS版本)。 解压后,在终端执行如下命令以移除quarantine属性:
+2. 对于 ffmpeg-kit,需要预先构建二进制文件。如果想节省时间,可以直接下载已构建好的二进制库,例如 `ffmpeg-kit-full-gpl-6.0-macos-xcframework.zip`(非 LTS 版本)。解压后,在终端执行如下命令以移除 quarantine 属性:
```
sudo xattr -rd com.apple.quarantine ./ffmpeg-kit-full-gpl-6.0-macos-xcframework
```
- (由于项目中止和版权原因,预构建的二进制文件已被移除,[这里](https://github.com/netdcy/ffmpeg-kit/releases/download/v6.0/ffmpeg-kit-full-gpl-6.0-macos-xcframework.zip)是原文件的备份。)
+ (由于项目中止和版权原因,预构建的二进制文件已被移除,[这里](https://github.com/netdcy/ffmpeg-kit/releases/download/v6.0/ffmpeg-kit-full-gpl-6.0-macos-xcframework.zip)是原文件的备份。)
3. 按如下所示组织目录结构:
@@ -115,9 +255,9 @@ Xcode 15.2+
└── Sources
```
-4. 用Xcode打开 `FlowVision.xcodeproj` ,在菜单栏中点击 'Product' -> 'Build For' -> 'Profiling' 。
-5. 然后 'Product' -> 'Show Build Folder in Finder',就可以看到构建好的app了 `Products/Release/FlowVision.app` 。
+4. 用 Xcode 打开 `FlowVision.xcodeproj`,在菜单栏中点击 'Product' -> 'Build For' -> 'Profiling'。
+5. 然后 'Product' -> 'Show Build Folder in Finder',就可以看到构建好的 app 了:`Products/Release/FlowVision.app`。
## 协议
-本项目使用GPL许可证。完整的许可证文本请参见 [LICENSE](https://github.com/netdcy/FlowVision/blob/main/LICENSE) 文件。
\ No newline at end of file
+本项目使用 GPL 许可证。完整的许可证文本请参见 [LICENSE](https://github.com/netdcy/FlowVision/blob/main/LICENSE) 文件。
diff --git a/Updater/FlowVisionUpdater.swift b/Updater/FlowVisionUpdater.swift
new file mode 100644
index 00000000..c317297a
--- /dev/null
+++ b/Updater/FlowVisionUpdater.swift
@@ -0,0 +1,132 @@
+import Darwin
+import Foundation
+
+private let allowedDownloadURL = URL(string: "https://github.com/mcxen/flowvision/releases/latest/download/FlowVision-macOS.zip")!
+private let expectedAppName = "FlowVision.app"
+private let expectedExecutable = "Contents/MacOS/FlowVision"
+
+enum UpdateError: LocalizedError {
+ case invalidArguments
+ case invalidPID
+ case invalidURL
+ case invalidDestination
+ case downloadFailed(Int32)
+ case extractionFailed(Int32)
+ case invalidArchive
+ case appDidNotExit
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidArguments: return "Invalid updater arguments."
+ case .invalidPID: return "Invalid FlowVision process identifier."
+ case .invalidURL: return "The update download address is not allowed."
+ case .invalidDestination: return "The FlowVision installation path is not allowed."
+ case let .downloadFailed(status): return "Update download failed (status \(status))."
+ case let .extractionFailed(status): return "Update extraction failed (status \(status))."
+ case .invalidArchive: return "The downloaded archive does not contain a valid FlowVision.app."
+ case .appDidNotExit: return "FlowVision did not exit before the update timeout."
+ }
+ }
+}
+
+@discardableResult
+private func run(_ executable: String, _ arguments: [String]) throws -> Int32 {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: executable)
+ process.arguments = arguments
+ try process.run()
+ process.waitUntilExit()
+ return process.terminationStatus
+}
+
+private func reopen(_ appURL: URL) {
+ _ = try? run("/usr/bin/open", [appURL.path])
+}
+
+private func processIsRunning(_ pid: pid_t) -> Bool {
+ errno = 0
+ return kill(pid, 0) == 0 || errno == EPERM
+}
+
+private func performUpdate() throws {
+ guard CommandLine.arguments.count == 4 else { throw UpdateError.invalidArguments }
+ guard let pid = pid_t(CommandLine.arguments[1]), pid > 1 else { throw UpdateError.invalidPID }
+ guard let suppliedURL = URL(string: CommandLine.arguments[2]),
+ suppliedURL.scheme?.lowercased() == "https",
+ suppliedURL.host?.lowercased() == allowedDownloadURL.host?.lowercased(),
+ suppliedURL.path == allowedDownloadURL.path else {
+ throw UpdateError.invalidURL
+ }
+
+ let destination = URL(fileURLWithPath: CommandLine.arguments[3], isDirectory: true).standardizedFileURL
+ guard destination.lastPathComponent == expectedAppName,
+ destination.pathExtension.lowercased() == "app",
+ FileManager.default.fileExists(atPath: destination.path) else {
+ throw UpdateError.invalidDestination
+ }
+
+ let fileManager = FileManager.default
+ let workDirectory = fileManager.temporaryDirectory
+ .appendingPathComponent("FlowVisionUpdate-\(UUID().uuidString)", isDirectory: true)
+ let archiveURL = workDirectory.appendingPathComponent("FlowVision-macOS.zip")
+ let extractedDirectory = workDirectory.appendingPathComponent("Extracted", isDirectory: true)
+ try fileManager.createDirectory(at: extractedDirectory, withIntermediateDirectories: true)
+ defer { try? fileManager.removeItem(at: workDirectory) }
+
+ let curlStatus = try run("/usr/bin/curl", [
+ "--fail", "--location", "--retry", "3", "--proto", "=https",
+ "--output", archiveURL.path, suppliedURL.absoluteString
+ ])
+ guard curlStatus == 0 else { throw UpdateError.downloadFailed(curlStatus) }
+
+ let dittoStatus = try run("/usr/bin/ditto", ["-x", "-k", archiveURL.path, extractedDirectory.path])
+ guard dittoStatus == 0 else { throw UpdateError.extractionFailed(dittoStatus) }
+
+ let extractedApp = extractedDirectory.appendingPathComponent(expectedAppName, isDirectory: true)
+ let extractedExecutable = extractedApp.appendingPathComponent(expectedExecutable)
+ guard fileManager.fileExists(atPath: extractedApp.path),
+ fileManager.isExecutableFile(atPath: extractedExecutable.path) else {
+ throw UpdateError.invalidArchive
+ }
+
+ let deadline = Date().addingTimeInterval(60)
+ while processIsRunning(pid), Date() < deadline {
+ usleep(200_000)
+ }
+ guard !processIsRunning(pid) else { throw UpdateError.appDidNotExit }
+
+ let parent = destination.deletingLastPathComponent()
+ guard fileManager.isWritableFile(atPath: parent.path) else { throw UpdateError.invalidDestination }
+ let backup = parent.appendingPathComponent(".FlowVision.app.update-backup-\(UUID().uuidString)", isDirectory: true)
+ var movedOriginal = false
+
+ do {
+ try fileManager.moveItem(at: destination, to: backup)
+ movedOriginal = true
+ try fileManager.moveItem(at: extractedApp, to: destination)
+ try? fileManager.removeItem(at: backup)
+ reopen(destination)
+ } catch {
+ if fileManager.fileExists(atPath: destination.path) {
+ try? fileManager.removeItem(at: destination)
+ }
+ if movedOriginal, fileManager.fileExists(atPath: backup.path) {
+ try? fileManager.moveItem(at: backup, to: destination)
+ }
+ reopen(destination)
+ throw error
+ }
+}
+
+do {
+ try performUpdate()
+} catch {
+ fputs("FlowVisionUpdater: \(error.localizedDescription)\n", stderr)
+ if CommandLine.arguments.count == 4 {
+ let destination = URL(fileURLWithPath: CommandLine.arguments[3], isDirectory: true).standardizedFileURL
+ if destination.lastPathComponent == expectedAppName {
+ reopen(destination)
+ }
+ }
+ exit(EXIT_FAILURE)
+}
diff --git a/Updater/UpdateManagerTests.swift b/Updater/UpdateManagerTests.swift
new file mode 100644
index 00000000..017e3d92
--- /dev/null
+++ b/Updater/UpdateManagerTests.swift
@@ -0,0 +1,19 @@
+import Foundation
+
+@main
+struct UpdateManagerTests {
+ static func main() {
+ let valid = URL(string: "https://github.com/mcxen/flowvision/releases/tag/v1.7.6")!
+ precondition(FlowVisionUpdateManager.version(fromReleaseURL: valid) == "1.7.6")
+
+ let malformed = URL(string: "https://github.com/mcxen/flowvision/releases/latest")!
+ precondition(FlowVisionUpdateManager.version(fromReleaseURL: malformed) == nil)
+
+ precondition(FlowVisionUpdateManager.isVersion("1.10.0", newerThan: "1.9.9"))
+ precondition(FlowVisionUpdateManager.isVersion("2.0", newerThan: "1.99.99"))
+ precondition(!FlowVisionUpdateManager.isVersion("1.7.6", newerThan: "1.7.6"))
+ precondition(!FlowVisionUpdateManager.isVersion("1.7.5", newerThan: "1.7.6"))
+
+ print("UpdateManager version tests passed")
+ }
+}
diff --git a/build_dmg.sh b/build_dmg.sh
new file mode 100755
index 00000000..83319e8c
--- /dev/null
+++ b/build_dmg.sh
@@ -0,0 +1,101 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+PROJECT_PATH="${PROJECT_PATH:-$PROJECT_ROOT/FlowVision.xcodeproj}"
+SCHEME="${SCHEME:-FlowVision}"
+CONFIGURATION="${CONFIGURATION:-Release}"
+DERIVED_DATA="${DERIVED_DATA:-$PROJECT_ROOT/build/DerivedData}"
+OUTPUT_DIR="${OUTPUT_DIR:-$PROJECT_ROOT/dist}"
+APP_NAME="${APP_NAME:-}"
+VOLUME_NAME="${VOLUME_NAME:-FlowVision}"
+DMG_NAME="${DMG_NAME:-FlowVision-macOS}"
+XCODEBUILD_EXTRA_ARGS="${XCODEBUILD_EXTRA_ARGS:-}"
+MPV_FRAMEWORKS_DIR="${MPV_FRAMEWORKS_DIR:-/Applications/IINA.app/Contents/Frameworks}"
+INCLUDE_MPV_RUNTIME="${INCLUDE_MPV_RUNTIME:-auto}" # auto, 1, 0
+
+# Optional signing controls
+APP_SIGN_IDENTITY="${APP_SIGN_IDENTITY:-}"
+DMG_SIGN_IDENTITY="${DMG_SIGN_IDENTITY:-}"
+ENABLE_CODESIGN="${ENABLE_CODESIGN:-1}" # 1=on, 0=off
+
+echo "[1/4] Building app (scheme=$SCHEME, configuration=$CONFIGURATION)..."
+read -r -a EXTRA_ARGS <<< "$XCODEBUILD_EXTRA_ARGS"
+xcodebuild \
+ -project "$PROJECT_PATH" \
+ -scheme "$SCHEME" \
+ -configuration "$CONFIGURATION" \
+ -destination "platform=macOS" \
+ -derivedDataPath "$DERIVED_DATA" \
+ clean build \
+ "${EXTRA_ARGS[@]}"
+
+BUILD_PRODUCTS_DIR="$DERIVED_DATA/Build/Products/$CONFIGURATION"
+
+if [[ -n "$APP_NAME" ]]; then
+ APP_PATH="$BUILD_PRODUCTS_DIR/$APP_NAME.app"
+else
+ APP_PATH="$(find "$BUILD_PRODUCTS_DIR" -maxdepth 1 -type d -name "*.app" | head -n 1)"
+fi
+
+if [[ -z "${APP_PATH:-}" || ! -d "$APP_PATH" ]]; then
+ echo "ERROR: .app not found in $BUILD_PRODUCTS_DIR"
+ echo "Tip: set APP_NAME explicitly, e.g. APP_NAME=FlowVisionDbg ./build_dmg.sh"
+ exit 1
+fi
+
+APP_BASENAME="$(basename "$APP_PATH")"
+
+if [[ "$APP_BASENAME" == "FlowVision.app" ]]; then
+ echo "[2/4] Building bundled update helper..."
+ "$PROJECT_ROOT/script/build_updater_helper.sh" "$APP_PATH"
+fi
+
+if [[ "$INCLUDE_MPV_RUNTIME" == "1" || ( "$INCLUDE_MPV_RUNTIME" == "auto" && -f "$MPV_FRAMEWORKS_DIR/libmpv.2.dylib" ) ]]; then
+ echo "[2/4] Copying mpv runtime from: $MPV_FRAMEWORKS_DIR"
+ mkdir -p "$APP_PATH/Contents/Frameworks"
+ rsync -a --delete \
+ --include='*.dylib' \
+ --exclude='*' \
+ "$MPV_FRAMEWORKS_DIR/" \
+ "$APP_PATH/Contents/Frameworks/"
+elif [[ "$INCLUDE_MPV_RUNTIME" == "1" ]]; then
+ echo "ERROR: mpv runtime not found at $MPV_FRAMEWORKS_DIR"
+ exit 1
+else
+ echo "[2/4] Skipping mpv runtime copy."
+fi
+
+if [[ "$ENABLE_CODESIGN" == "1" && -n "$APP_SIGN_IDENTITY" ]]; then
+ echo "[2/4] Re-signing app with identity: $APP_SIGN_IDENTITY"
+ codesign --force --deep --options runtime --timestamp --sign "$APP_SIGN_IDENTITY" "$APP_PATH"
+fi
+
+echo "[3/4] Creating DMG..."
+mkdir -p "$OUTPUT_DIR"
+STAGE_DIR="$(mktemp -d "$PROJECT_ROOT/.dmg_stage.XXXXXX")"
+trap 'rm -rf "$STAGE_DIR"' EXIT
+
+cp -R "$APP_PATH" "$STAGE_DIR/"
+ln -s /Applications "$STAGE_DIR/Applications"
+
+DMG_PATH="$OUTPUT_DIR/$DMG_NAME.dmg"
+rm -f "$DMG_PATH"
+
+hdiutil create \
+ -volname "$VOLUME_NAME" \
+ -srcfolder "$STAGE_DIR" \
+ -ov \
+ -format UDZO \
+ "$DMG_PATH"
+
+if [[ "$ENABLE_CODESIGN" == "1" && -n "$DMG_SIGN_IDENTITY" ]]; then
+ echo "[4/4] Signing DMG with identity: $DMG_SIGN_IDENTITY"
+ codesign --force --timestamp --sign "$DMG_SIGN_IDENTITY" "$DMG_PATH"
+else
+ echo "[4/4] Skipping DMG codesign."
+fi
+
+echo "DONE: $DMG_PATH"
diff --git a/docs/PixPin_2026-04-23_12-39-32.png b/docs/PixPin_2026-04-23_12-39-32.png
new file mode 100644
index 00000000..f844008e
Binary files /dev/null and b/docs/PixPin_2026-04-23_12-39-32.png differ
diff --git a/public/doc/ARCHITECTURE.md b/public/doc/ARCHITECTURE.md
new file mode 100644
index 00000000..6b0bf924
--- /dev/null
+++ b/public/doc/ARCHITECTURE.md
@@ -0,0 +1,425 @@
+# FlowVision 项目架构文档
+
+## 项目概述
+
+FlowVision 是一款 macOS 瀑布流风格图片查看器,支持图片和视频浏览,具有以下特性:
+- 自适应布局模式,支持明暗主题
+- 便捷的文件管理(类似 Finder)
+- 右键手势快速导航
+- 大量图片目录的性能优化
+- 高质量缩放
+- 视频播放支持
+- HDR 显示支持
+- 递归浏览模式
+
+## 快速导航
+
+仓库级修改规则和构建命令见 [`AGENTS.md`](../../AGENTS.md)。定位代码时先搜索下列稳定锚点,避免通读大型扩展文件。
+
+| 场景 | 调用入口 / 核心锚点 | 主要文件 |
+|---|---|---|
+| 批量重命名 | `actBatchRenameSelectedItems` → `handleBatchRenameSelectedItems` | `Views/CustomCollectionViewItem.swift`、`Views/CustomOutlineView.swift`、`ViewControllerExtension/FileOperation.swift` |
+| 快捷重命名当前目录 | `handleQuickRenameInCurrentFolder` → `executeFileRenameMappingsAsync` | `ViewControllerExtension/FileOperation.swift` |
+| 原位更新名称 | `applyRenameMappingsInPlace` | `ViewControllerExtension/FileOperation.swift` |
+| 配置目录异步复制 | `handleCopyToPhotoFolder1` / `handleCopySelectedVideosToPhotoFolder2` → `handleCopyToConfiguredFolder` | `ViewControllerExtension/KeyShortcut.swift`、`ViewControllerExtension/FileOperation.swift` |
+| 文件系统刷新与定位 | `scheduledRefresh` → `selectItemsNewChanged` | `ViewController.swift`、`ViewControllerExtension/FileSystem.swift` |
+| 播放状态 | `currentPlayingURL`、`restorePlayURL` | `ViewController.swift`、`Views/LargeImageView.swift`、`Views/CustomCollectionViewItem.swift` |
+| 邻近媒体预热 | `preloadLargeImage` → `MediaPreheatManager` | `ViewControllerExtension/LargeImage.swift`、`Common/VideoProcess.swift` |
+| FFmpeg | `FFmpegKit` | `Common/FFmpegKit.swift` |
+
+常用定位命令:
+
+```bash
+rg -n 'handleQuickRenameInCurrentFolder|applyRenameMappingsInPlace' FlowVision/Sources
+rg -n 'handleCopyToConfiguredFolder|isInFileOperation' FlowVision/Sources
+rg -n 'currentPlayingURL|restorePlayURL' FlowVision/Sources
+```
+
+## 系统要求
+
+- macOS 11.0 或更高版本
+- Xcode 15.2+
+
+## 目录结构
+
+```
+FlowVision/
+├── FlowVision.xcodeproj # Xcode 项目文件
+├── FlowVision/
+│ ├── Info.plist # 应用程序配置
+│ ├── FlowVision.entitlements # 应用权限配置
+│ ├── Resources/ # 资源文件
+│ │ ├── Assets.xcassets/ # 图片资源
+│ │ ├── Base.lproj/ # 基础本地化资源
+│ │ ├── mul.lproj/ # 多语言本地化资源
+│ │ ├── Localizable.xcstrings # 本地化字符串
+│ │ └── icon.png # 应用图标
+│ └── Sources/ # 源代码目录
+│ ├── AppDelegate.swift # 应用程序代理
+│ ├── ViewController.swift # 主视图控制器
+│ ├── WindowController.swift # 窗口控制器
+│ ├── Common/ # 公共模块
+│ ├── Views/ # 视图组件
+│ ├── ViewControllerExtension/ # 视图控制器扩展
+│ └── SettingsViews/ # 设置界面
+├── docs/ # 文档目录
+├── public/ # 公共资源
+├── build_dmg.sh # DMG 打包脚本
+├── Base.xcconfig # 基础配置
+└── LocalDev.xcconfig.template # 本地开发配置模板
+```
+
+## 核心模块说明
+
+### 1. 入口文件
+
+| 文件 | 说明 |
+|------|------|
+| `AppDelegate.swift` | 应用程序入口,处理应用生命周期、全局状态管理、菜单配置等 |
+| `ViewController.swift` | 主视图控制器,核心业务逻辑,管理图片展示、用户交互 |
+| `WindowController.swift` | 窗口控制器,管理窗口行为、标题栏、工具栏等 |
+
+### 2. Common 模块 (`Sources/Common/`)
+
+公共工具和数据模型,被其他模块共享使用。
+
+| 文件 | 大小 | 说明 |
+|------|------|------|
+| `Common.swift` | 40KB | 通用工具函数、扩展方法、辅助功能 |
+| `DataModel.swift` | 41KB | 数据模型定义,包含排序键、文件项模型等 |
+| `ImageProcess.swift` | 106KB | 图片处理核心逻辑,缩略图生成、图片解码等 |
+| `VideoProcess.swift` | 3KB | 视频处理相关功能 |
+| `FFmpegKit.swift` | 7KB | FFmpeg 集成封装 |
+| `FinderTag.swift` | 31KB | macOS Finder 标签功能集成 |
+| `Log.swift` | 12KB | 日志系统 |
+| `GlobalVariable.swift` | 9KB | 全局变量和配置 |
+| `Enum.swift` | 1KB | 枚举定义(文件类型、排序类型、布局类型等) |
+| `RefCode.swift` | 1KB | 引用代码 |
+| `TempVariable.swift` | 0.1KB | 临时变量 |
+
+#### 关键枚举定义 (`Enum.swift`)
+
+```swift
+// 文件类型
+enum FileType: Int, Codable {
+ case image, video, other, folder, notSet, all
+}
+
+// 右键手势方向
+enum RightMouseGestureDirection: Int, Codable {
+ case right, left, up, down, up_right, up_left, down_left, down_right, zero, forward, back
+}
+
+// 布局类型
+enum LayoutType: Int, Codable {
+ case justified, waterfall, grid, detail
+}
+
+// 排序类型
+enum SortType: Int, Codable {
+ case pathA, pathZ, extA, extZ, sizeA, sizeZ,
+ createDateA, createDateZ, modDateA, modDateZ,
+ addDateA, addDateZ, random, exifDateA, exifDateZ,
+ exifPixelA, exifPixelZ
+}
+```
+
+### 3. Views 模块 (`Sources/Views/`)
+
+自定义视图组件,负责 UI 渲染。
+
+| 文件 | 大小 | 说明 |
+|------|------|------|
+| `CustomCollectionView.swift` | 18KB | 自定义集合视图,瀑布流布局核心 |
+| `CustomCollectionViewItem.swift` | 76KB | 集合视图单元格,缩略图显示 |
+| `CustomOutlineView.swift` | 23KB | 目录树视图 |
+| `CustomOutlineViewManager.swift` | 16KB | 目录树管理器 |
+| `LargeImageView.swift` | 116KB | 大图查看视图 |
+| `ImageEditingView.swift` | 34KB | 图片编辑视图 |
+| `CoreAreaView.swift` | 12KB | 核心区域视图 |
+| `Layout.swift` | 12KB | 布局管理 |
+| `CustomImageView.swift` | 8KB | 自定义图片视图 |
+| `CustomProfileView.swift` | 23KB | 配置文件视图 |
+| `FavoritesPopoverViewController.swift` | 17KB | 收藏夹弹出视图 |
+| `DrawingView.swift` | 5KB | 绘图视图 |
+| `CustomSplitView.swift` | 2KB | 自定义分割视图 |
+| `CustomPathControl.swift` | 0.2KB | 路径控件 |
+| `CustomEffectView.swift` | 2KB | 自定义效果视图 |
+| `CustomCollectionViewManager.swift` | 6KB | 集合视图管理器 |
+| `CustomCollectionViewItem.xib` | 5KB | 界面布局文件 |
+
+### 4. ViewControllerExtension 模块 (`Sources/ViewControllerExtension/`)
+
+视图控制器功能扩展,按职责分离代码。
+
+| 文件 | 大小 | 说明 |
+|------|------|------|
+| `FileOperation.swift` | 93KB | 文件操作(复制、移动、删除、重命名等) |
+| `KeyShortcut.swift` | 52KB | 键盘快捷键处理 |
+| `FileSystem.swift` | 76KB | 文件系统操作、目录遍历 |
+| `LargeImage.swift` | 47KB | 大图查看功能 |
+| `EventHandler.swift` | 27KB | 事件处理 |
+| `Search.swift` | 25KB | 搜索功能 |
+| `WindowManagement.swift` | 19KB | 窗口管理 |
+| `ArrowKeyLocate.swift` | 13KB | 方向键导航 |
+| `DirTree.swift` | 7KB | 目录树操作 |
+| `AutoScrollPlay.swift` | 5KB | 自动滚动播放 |
+| `RightMouseGesture.swift` | 6KB | 右键手势识别 |
+| `ProgressBar.swift` | 9KB | 进度条显示 |
+| `MemoryManagement.swift` | 3KB | 内存管理 |
+| `LayoutManagement.swift` | 11KB | 布局管理 |
+| `LayoutProfileConfig.swift` | 6KB | 布局配置文件管理 |
+
+### 5. SettingsViews 模块 (`Sources/SettingsViews/`)
+
+设置界面相关视图。
+
+| 文件 | 说明 |
+|------|------|
+| `GeneralSettingsViewController.swift` | 通用设置(启动、外观等) |
+| `ActionsSettingsViewController.swift` | 操作设置(快捷键、手势等) |
+| `CustomSettingsViewController.swift` | 自定义设置 |
+| `AdvancedSettingsViewController.swift` | 高级设置(性能、内存等) |
+| `TaggingSettingsViewController.swift` | 标签设置 |
+| `DemoSettingsViewController.swift` | 演示设置 |
+
+## 架构设计
+
+### MVC 架构
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ Application Layer │
+│ ┌─────────────────┐ │
+│ │ AppDelegate │ ← 应用入口、全局状态、菜单管理 │
+│ └─────────────────┘ │
+├─────────────────────────────────────────────────────────────┤
+│ Controller Layer │
+│ ┌─────────────────┐ ┌──────────────────────┐ │
+│ │ WindowController│ │ ViewController │ │
+│ │ │ │ (Main Controller) │ │
+│ └─────────────────┘ └──────────────────────┘ │
+│ │ │
+│ ┌───────────────┼───────────────┐ │
+│ ↓ ↓ ↓ │
+│ ┌────────────────┐ ┌─────────────┐ ┌──────────────┐ │
+│ │ FileOperation │ │ KeyShortcut │ │ EventHandler │ ... │
+│ └────────────────┘ └─────────────┘ └──────────────┘ │
+├─────────────────────────────────────────────────────────────┤
+│ View Layer │
+│ ┌────────────────────┐ ┌────────────────────┐ │
+│ │ CustomCollectionView│ │ CustomOutlineView │ │
+│ │ (瀑布流缩略图) │ │ (目录树) │ │
+│ └────────────────────┘ └────────────────────┘ │
+│ ┌────────────────────┐ ┌────────────────────┐ │
+│ │ LargeImageView │ │ ImageEditingView │ │
+│ │ (大图查看) │ │ (图片编辑) │ │
+│ └────────────────────┘ └────────────────────┘ │
+├─────────────────────────────────────────────────────────────┤
+│ Model Layer │
+│ ┌─────────────────────────────────────────────┐ │
+│ │ DataModel.swift (SortKey, FileItem等) │ │
+│ │ GlobalVariable.swift (GlobalVar) │ │
+│ └─────────────────────────────────────────────┘ │
+├─────────────────────────────────────────────────────────────┤
+│ Service Layer │
+│ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │
+│ │ImageProcess │ │ VideoProcess │ │ FinderTag │ │
+│ └─────────────┘ └──────────────┘ └────────────────┘ │
+│ ┌─────────────┐ ┌──────────────┐ ┌────────────────┐ │
+│ │ FileSystem │ │ Log │ │ FFmpegKit │ │
+│ └─────────────┘ └──────────────┘ └────────────────┘ │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### 数据流向
+
+```
+用户操作 → EventHandler/KeyShortcut
+ ↓
+ ViewController
+ ↓
+ ┌───────────┼───────────┐
+ ↓ ↓ ↓
+FileOperation Search FileSystem
+ ↓ ↓ ↓
+ └───────────┼───────────┘
+ ↓
+ DataModel 更新
+ ↓
+ View 刷新
+```
+
+## 文件操作、线程与刷新策略
+
+### 重命名调用链
+
+```text
+缩略图/目录树菜单 当前目录快捷键
+ │ │
+ ▼ ▼
+handleBatchRenameSelectedItems handleQuickRenameInCurrentFolder
+ │ │
+预览表格(原名称 / 新名称) 后台生成重命名计划
+ └──────────────┬───────────────────────┘
+ ▼
+ executeFileRenameMappings / executeFileRenameMappingsAsync
+ │
+ 冲突预检 → 临时名 → 最终名 → 失败回滚
+ │
+ 更新 Enhanced Index 与 Undo
+ │
+ ┌────────────┴────────────┐
+ ▼ ▼
+ applyRenameMappingsInPlace 条件不满足的兜底刷新
+ 路径/名称/排序原位更新 scheduledRefresh + 恢复滚动
+```
+
+批量重命名支持前缀、后缀、查找替换,以及 `{name}`、`{index}`、`{index:03}`、`{folder}`、`{ext}` 等变量。确认前使用两列表格展示原名称和新名称。执行器采用两阶段临时路径,避免交换名称或排序链式重命名互相覆盖。
+
+### 复制到配置目录
+
+图片目录 1 与视频目录 2 共用 `handleCopyToConfiguredFolder`:
+
+```text
+快捷键/菜单
+ → 收集并校验 URL、目标目录、自复制关系(主线程)
+ → FileManager.copyItem、重名避让、进度统计(后台队列)
+ → 状态复位、错误提示、目标定位与刷新(主线程)
+```
+
+该路径不依赖 Finder 剪贴板。调用方包括 `handleCopyToPhotoFolder1`、`handleCopySelectedVideosToPhotoFolder2` 和当前视频复制入口。
+
+### 线程约定
+
+| 工作 | 所在线程 |
+|---|---|
+| 文件枚举、批量冲突检查、复制、两阶段重命名、Enhanced Index 更新 | 后台队列 |
+| NSAlert、进度控件、collection/outline view、窗口标题、UndoManager | 主线程 |
+| `publicVar.isInFileOperation` 的生命周期切换 | 主线程统一管理,所有退出路径复位 |
+
+`isInFileOperation` 使文件监听刷新在应用主动操作期间让路,防止中间临时文件被扫描进模型。后台任务不得直接访问或修改 AppKit 对象。
+
+### 刷新决策
+
+| 变更类型 | 默认策略 | 原因 |
+|---|---|---|
+| 仅重命名 | `applyRenameMappingsInPlace` | 复用 `FileModel` 和现有 cell,不闪烁、不重启播放器 |
+| 重命名但模型不完整/目录已切换 | `scheduledRefresh`,随后恢复滚动位置 | 确保磁盘和模型最终一致 |
+| 创建、复制、移动到当前视图 | 刷新并使用 `filesForLocateAfterChange` 定位新目标 | 新文件尚未存在于当前 Map |
+| 移动到视图外或移动当前目录的祖先 | 刷新并恢复滚动位置;同步改写当前目录路径 | 避免跳回顶部或显示空目录 |
+| 删除、目录结构变化 | 文件系统刷新 | 需要重新枚举或更新目录树 |
+
+原位重命名会克隆新的 `SortKeyFile` 并重建 `BTree.Map`,但继续复用 `FileModel`。可见 cell 只更新 URL、名称、tooltip 和必要的排序移动,不调用完整配置流程。大图路径、`currentPlayingURL`、`restorePlayURL`、Finder 打开路径和窗口标题同步替换,从而保留视频播放进度。
+
+兜底刷新使用 `collectionScrollRestoreAfterRefresh` 保存当前 clip view 原点,并在 `selectItemsNewChanged(isFinal:)` 完成后恢复。快捷重命名不设置 `filesForLocateAfterChange`,否则会错误滚到第一个改名项目。
+
+重命名引起排序变化时,选中状态按 `FileModel` 身份映射到新索引,不沿用旧索引。移动操作会同步改写当前目录、播放器和大图路径;只有目标位于当前视图时才自动定位目标,移出当前视图则保持原滚动位置。
+
+无目标冲突的移动在后台队列执行,完成后一次性回主线程更新路径、进度和刷新状态;只有需要逐项询问覆盖、合并或自动重命名时才走交互式分支。自身/子目录判断按完整路径组件边界处理,不能用裸字符串前缀比较。
+
+从当前视图移出项目时,刷新前记录鼠标所在拖拽项的布局位置和相邻未移动项目。刷新后以该相邻项目作为视口锚点恢复到原屏幕坐标,避免删除前序项目造成列表跳到顶部;锚点不存在时才退回保存的 clip view 原点。
+
+集合视图开始多选拖拽时一次性缓存所有选中 URL,避免 `pasteboardWriterForItemAt` 对每个视频重复加锁查询。超过 8 个项目时不渲染每个视频缩略图/播放器作为拖拽图,而是使用单个带数量标记的轻量文件堆叠预览;pasteboard 仍保留每个文件 URL。
+
+放下多个项目后的目标重名检查也在后台批量执行。无冲突时直接在同一后台任务完成移动;有冲突时用拖拽 URL 快照回到主线程进入原有覆盖、合并和自动重命名交互,避免对网络盘逐项同步探测。
+
+### FFmpeg 剪切边界
+
+输入端 `-ss` 配合 `-c copy` 是关键帧级无损剪切,不是帧精确剪切。iPhone MOV 可能同时包含视频、多个音轨和 `mebx` data 流;无差别映射所有流会把各自的原始时间轴带入输出,可能表现为开头黑屏或容器时长异常。
+
+- 只需要画面和声音:使用 `-map 0:v:0 -map 0:a?`,不要默认映射 data 流。
+- 需要准确起止:视频重编码,并显式处理时间戳;音频可按兼容性决定复制或重编码。
+- 需要无损快速导出:接受切点对齐附近关键帧,并用 `ffprobe` 同时检查 format duration 和各 stream 的 start/duration。
+
+## 邻近媒体预热与 SMB 播放
+
+大图浏览每次切换位置后,以当前媒体为中心收集前 5 个和后 5 个可浏览媒体。`ViewController` 持有独立的 `MediaPreheatManager`,因此多窗口之间不会相互取消任务。
+
+```text
+changeLargeImage(仅浏览位置变化)
+ → preloadLargeImage 收集 [-5 ... 当前 ... +5]
+ → beginWindow:取消旧任务、更新 generation
+ ├─ 图片队列(并发 2)→ LargeImageProcessor 解码缓存
+ └─ 视频队列(并发 1)→ AVAssetReader 读取前 5 秒压缩样本
+ → macOS Unified Buffer Cache
+ → 保留已解析 AVURLAsset
+```
+
+视频不生成本地 5 秒临时片段,因为片段切回原片会引入时间轴、音轨和关键帧拼接问题。`AVAssetReader` 的目的不是提前播放或长期持有解码帧,而是将 SMB 上即将访问的文件区间预读进系统文件页缓存。邻项切成当前项后:
+
+- libmpv 强制开启有界缓存,先维持约 5 秒目标缓冲,再在播放过程中持续向前读取;demuxer 前向上限为 128 MiB,回看上限为 32 MiB。
+- AVPlayer 回退路径优先从 `MediaPreheatManager` 取已解析的 `AVURLAsset`,并继续使用 `preferredForwardBufferDuration = 5`。
+- 当前视频由播放器读取,预热器只处理邻近视频,避免同一 SMB 文件被两条读取链路竞争。
+
+预热任务带 generation。快速连续翻页时,排队任务会被取消,已经运行的视频读取循环检测 generation 并取消 `AVAssetReader`。图片和视频队列分别限制并发,避免预热吞掉当前播放所需带宽。缩放、旋转等不改变浏览位置的刷新不重建预热窗口。
+
+## 支持的文件格式
+
+### 图片格式
+- 常见格式:jpg, jpeg, png, gif, bmp, webp, tiff, ico, svg
+- 高质量格式:heif, heic, hif, avif, jxl, jp2
+- RAW 格式:crw, cr2, cr3, nef, nrw, arw, srf, sr2, rw2, orf, raf, pef, dng, raw, rwl, x3f, 3fr, fff, iiq, mos, dcr, erf, mrw, gpr, srw
+- 设计文件:ai, psd
+
+### 视频格式
+- 原生支持:mp4, mov, m2ts, ts, mpeg, mpg, m4v, vob
+- FFmpeg 支持:mkv, mts, avi, flv, f4v, asf, wmv, rmvb, rm, webm, divx, xvid, 3gp, 3g2
+
+## 依赖库
+
+| 库名 | 用途 |
+|------|------|
+| ffmpeg-kit | 视频解码和处理 |
+| BTree | 高效有序数据结构 |
+| Settings | 设置界面框架 |
+
+## 全局配置 (`GlobalVar`)
+
+主要配置项包括:
+- 窗口限制:`WINDOW_LIMIT = 16`
+- 缩略图预加载范围:前 20 张,后 40 张
+- 内存使用限制:默认 4000MB
+- 缩略图线程数:本地 8,外部 1
+- 文件夹搜索深度:本地 4,外部 0
+- 滚动灵敏度
+- 各种显示和行为选项
+
+## 布局模式
+
+1. **Justified(两端对齐)**:图片行两端对齐,类似 Google Photos
+2. **Waterfall(瀑布流)**:传统瀑布流布局
+3. **Grid(网格)**:均匀网格布局
+4. **Detail(详情)**:详细信息列表视图
+
+## 用户交互
+
+### 右键手势
+- 右/左:切换到下一个/上一个含图片的文件夹
+- 上:切换到父目录
+- 下:返回上一个目录
+- 右上:切换到同级下一个文件夹
+- 右下:关闭标签/窗口
+
+### 键盘快捷键
+- W:等同于右键手势向上
+- A/D:等同于右键手势左/右
+- S:等同于右键手势向下
+
+### 图片查看操作
+- 双击:打开/关闭图片
+- 右键/左键 + 滚轮:缩放
+- 中键拖动:移动窗口
+- 长按左键:100% 缩放
+- 长按右键:适应窗口
+
+## 构建说明
+
+1. 克隆项目和依赖库
+2. 构建 ffmpeg-kit 或下载预编译版本
+3. 按指定目录结构组织依赖
+4. 使用 Xcode 构建 Release 版本
+
+---
+
+*最后维护日期:2026-07-12*
diff --git a/public/doc/feature-notes-2026-04-20.md b/public/doc/feature-notes-2026-04-20.md
new file mode 100644
index 00000000..e4b2f017
--- /dev/null
+++ b/public/doc/feature-notes-2026-04-20.md
@@ -0,0 +1,77 @@
+# FlowVision Feature Notes (2026-04-20)
+
+## Overview
+
+This note summarizes the recent file-action and archive-related features added to FlowVision.
+
+## Custom folder copy shortcuts
+
+- `Photo Folder 1` keeps the existing behavior for quick-copying selected items.
+- `Video Folder 2` is added for copying videos to a second configured folder.
+- Both folder actions are configured from the `Actions` settings pane.
+- `Folder 2` works in two contexts:
+ - collection view: copies selected video files
+ - large video view: copies the current video file
+
+## Shortcut routing
+
+- Shortcut handling stays inside `KeyShortcut.swift`.
+- Folder shortcuts are checked before built-in no-modifier shortcuts.
+- This means a custom folder shortcut can intentionally override a built-in key.
+- To reduce accidental conflicts:
+ - `Video Folder 2` default was moved from `F` to `F4`
+ - the settings pane now shows a built-in shortcut reference
+ - the settings pane also shows warning text when Folder 1 / Folder 2 use the same key or override a built-in action
+
+## Supported custom shortcut candidates
+
+- Letters: `A-Z`
+- Digits: `0-9`
+- Punctuation currently exposed in settings: `=`, `-`, `,`, `.`, `[`, `]`
+- Function keys: `F1-F12`
+
+## Folder copy implementation details
+
+- Physical files are copied with the existing pasteboard + paste flow to stay aligned with current app behavior.
+- Virtual archive entries are copied without extracting the whole archive:
+ - parse the virtual archive path
+ - stream the selected entry via `bsdtar -xOf`
+ - write bytes directly to the destination file
+- Copy completion reuses the existing bottom-right toast overlay.
+
+## Archive browsing
+
+- Archive browsing still uses the virtual-folder model:
+ - archive file path is converted to a virtual archive root
+ - archive entry listing is resolved with `bsdtar -tf`
+ - image bytes are streamed per-entry when needed
+- This avoids creating a temporary extracted directory for browsing image content.
+
+## Archive extraction actions
+
+- New context-menu actions were added for archive files:
+ - `解压到当前目录`
+ - `解压并删除压缩包`
+- These actions are available in:
+ - collection item context menu
+ - outline/tree context menu
+
+## Archive extraction behavior
+
+- Extraction is implemented in `FileOperation.swift`.
+- Supported archive inputs reuse the existing `isSupportedArchiveURL(...)` check.
+- Extraction uses `/usr/bin/bsdtar` with:
+
+```bash
+bsdtar -xf -C
+```
+
+- Each archive is extracted into a unique sibling folder named from the archive base name.
+- Multi-part names such as `.tar.gz`, `.tar.bz2`, and `.tar.xz` are stripped correctly when generating the destination folder name.
+- When `解压并删除压缩包` is chosen, the source archive is moved to Trash after successful extraction.
+
+## Current tradeoffs
+
+- Custom folder shortcuts can still override built-in keys if the user explicitly chooses them.
+- The settings pane warns about those conflicts, but does not hard-block the choice.
+- Archive extraction currently runs synchronously and does not yet show a dedicated progress overlay.
diff --git a/script/build_and_run.sh b/script/build_and_run.sh
new file mode 100755
index 00000000..208e4252
--- /dev/null
+++ b/script/build_and_run.sh
@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+MODE="${1:-run}"
+APP_NAME="FlowVision"
+BUNDLE_ID="com.flowvision.FlowVision"
+ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+DERIVED_DATA="$ROOT_DIR/build/DerivedData"
+APP_BUNDLE="$DERIVED_DATA/Build/Products/Release/$APP_NAME.app"
+APP_BINARY="$APP_BUNDLE/Contents/MacOS/$APP_NAME"
+
+pkill -x "$APP_NAME" >/dev/null 2>&1 || true
+
+xcodebuild -quiet \
+ -project "$ROOT_DIR/FlowVision.xcodeproj" \
+ -scheme "$APP_NAME" \
+ -configuration Release \
+ -destination 'platform=macOS' \
+ -derivedDataPath "$DERIVED_DATA" \
+ CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build
+
+"$ROOT_DIR/script/build_updater_helper.sh" "$APP_BUNDLE"
+/usr/bin/codesign --force --deep --sign - "$APP_BUNDLE"
+
+open_app() {
+ /usr/bin/open -n "$APP_BUNDLE"
+}
+
+case "$MODE" in
+ run)
+ open_app
+ ;;
+ --debug|debug)
+ lldb -- "$APP_BINARY"
+ ;;
+ --logs|logs)
+ open_app
+ /usr/bin/log stream --info --style compact --predicate "process == \"$APP_NAME\""
+ ;;
+ --telemetry|telemetry)
+ open_app
+ /usr/bin/log stream --info --style compact --predicate "subsystem == \"$BUNDLE_ID\""
+ ;;
+ --verify|verify)
+ open_app
+ for _ in 1 2 3 4 5; do
+ pgrep -x "$APP_NAME" >/dev/null && exit 0
+ sleep 1
+ done
+ exit 1
+ ;;
+ *)
+ echo "usage: $0 [run|--debug|--logs|--telemetry|--verify]" >&2
+ exit 2
+ ;;
+esac
diff --git a/script/build_updater_helper.sh b/script/build_updater_helper.sh
new file mode 100755
index 00000000..4e84a83f
--- /dev/null
+++ b/script/build_updater_helper.sh
@@ -0,0 +1,34 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ $# -ne 1 ]]; then
+ echo "usage: $0 /path/to/FlowVision.app" >&2
+ exit 2
+fi
+
+PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+APP_PATH="$1"
+SOURCE_PATH="$PROJECT_ROOT/Updater/FlowVisionUpdater.swift"
+OUTPUT_PATH="$APP_PATH/Contents/MacOS/FlowVisionUpdater"
+WORK_DIR="$(mktemp -d)"
+trap 'rm -rf "$WORK_DIR"' EXIT
+
+if [[ ! -d "$APP_PATH" || "$(basename "$APP_PATH")" != "FlowVision.app" ]]; then
+ echo "Expected a packaged FlowVision.app: $APP_PATH" >&2
+ exit 1
+fi
+
+for arch in arm64 x86_64; do
+ xcrun swiftc \
+ -O \
+ -target "${arch}-apple-macos11.0" \
+ "$SOURCE_PATH" \
+ -o "$WORK_DIR/FlowVisionUpdater-$arch"
+done
+
+lipo -create \
+ "$WORK_DIR/FlowVisionUpdater-arm64" \
+ "$WORK_DIR/FlowVisionUpdater-x86_64" \
+ -output "$OUTPUT_PATH"
+
+chmod 755 "$OUTPUT_PATH"