diff --git a/.DS_Store b/.DS_Store
new file mode 100644
index 00000000..5257c7ba
Binary files /dev/null and b/.DS_Store differ
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..e86bf26c
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,136 @@
+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
+ echo "version=$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: "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"
+
+ - 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/.mindfs/sessions/1776746378-31906b751c86.jsonl b/.mindfs/sessions/1776746378-31906b751c86.jsonl
new file mode 100644
index 00000000..ee7c51a6
--- /dev/null
+++ b/.mindfs/sessions/1776746378-31906b751c86.jsonl
@@ -0,0 +1,4 @@
+{"seq":1,"role":"user","agent":"codex","effort":"medium","content":"你看一下现在的功能,增加一个SMB的可能性,不要改代码","timestamp":"2026-04-21T04:40:41.976786Z"}
+{"seq":2,"role":"agent","agent":"codex","effort":"medium","content":"我先看一下当前项目的功能边界和已有接入方式,重点判断在不改代码的前提下,`SMB` 能加到什么程度、卡点在哪里。\n\n我先沿着文档和文件系统相关代码看,判断现在支持的是“本地路径浏览”还是已经抽象成了可扩展的数据源。这个结论会直接决定 SMB 是“配置级可行”还是“需要开发”。\n\n代码里已经出现了不少“network drives / mounted directories”的处理痕迹。我继续看文件系统、权限、入口选择这几块,确认它是不是已经能“通过系统挂载后的 SMB”工作,以及缺的是不是“原生 SMB 连接能力”。\n\n结论已经比较清楚了:当前代码把“已挂载的网络盘”当成外置卷处理,但没有任何“主动发起 SMB 连接”的能力。我再补两处证据,确认 UI 和设置层面对网络盘也是按“挂载后可用”设计的。\n\n看过当前实现后,结论很明确:\n\n如果你说的 `SMB` 是“支持 Finder 已经挂载好的 SMB 共享目录”,那这个项目其实已经基本具备了,不用改代码就能用。代码里会把已挂载卷统一识别成 external volume,并对 network drive 做专门的性能策略和提示,比如 [Common.swift](/Users/mcx/Documents/OpenSpring/FlowVision/FlowVision/Sources/Common/Common.swift:671)、[FileSystem.swift](/Users/mcx/Documents/OpenSpring/FlowVision/FlowVision/Sources/ViewControllerExtension/FileSystem.swift:217)、[DirTree.swift](/Users/mcx/Documents/OpenSpring/FlowVision/FlowVision/Sources/ViewControllerExtension/DirTree.swift:95),设置文案里也直接写了 network drives 的场景,比如 [AdvancedSettingsViewController.xcstrings](/Users/mcx/Documents/OpenSpring/FlowVision/FlowVision/Sources/SettingsViews/mul.lproj/AdvancedSettingsViewController.xcstrings:1541) 和 [TaggingSettingsViewController.xcstrings](/Users/mcx/Documents/OpenSpring/FlowVision/FlowVision/Sources/SettingsViews/mul.lproj/TaggingSettingsViewController.xcstrings:100)。从入口看,应用也是通过 `NSOpenPanel` 选文件/文件夹、再按普通文件系统路径处理,[AppDelegate.swift](/Users/mcx/Documents/OpenSpring/FlowVision/FlowVision/Sources/AppDelegate.swift:492) 这一套对“已挂载的 SMB 卷”是兼容的。\n\n但如果你说的 `SMB` 是“应用里直接输入 `smb://...`,自己发起连接、认证、记住账号、自动重连”,那现在没有这个能力,而且不属于小改。当前架构本质上是 `FileManager + file:// 已挂载路径` 模型,没有看到 `smb://` 协议处理、挂载、认证、会话管理相关代码;同时 README 还写了“无网络请求”,见 [README_zh.md](/Users/mcx/Documents/OpenSpring/FlowVision/README_zh.md:70)。这意味着原生 SMB 接入不只是加一个入口,而是要改产品边界。\n\n所以实际判断是:\n\n- 已挂载 SMB 共享:现在就有可行性,属于“已有能力,只是没明说”。\n- 应用内原生 SMB 连接:当前代码基础上可做,但必须开发,且会影响架构和产品承诺。\n\n这次我没有改代码。 \n如果你需要,我下一步可以继续只做分析,帮你整理一版“最小 SMB 支持方案”的改动范围清单。","timestamp":"2026-04-21T04:40:41.978025Z"}
+{"seq":3,"role":"user","agent":"codex","effort":"medium","content":"如何实现brew tap进我的github项目,完成我这个版本的安装","timestamp":"2026-04-21T04:42:26.448053Z"}
+{"seq":4,"role":"agent","agent":"codex","effort":"medium","content":"我先看一下仓库里当前的发布产物和 README 现有安装说明,给你一套能直接落到这个项目上的 `brew tap` 方案,先不改代码。\n\n我已经确认 README 里现在写的是直接 `brew install flowvision`,但仓库里还没看到 tap/cask 配置。下一步我把这个项目对应的 Homebrew 发布方式具体化到可执行命令和 cask 文件。\n\n这个项目要走 `brew tap`,应该用 `Homebrew Cask`,不是普通 formula。因为你发的是 macOS GUI 应用 `.dmg`,不是命令行工具。\n\n按你现在这个仓库,最直接可落地的做法是:\n\n**1. 新建一个 tap 仓库**\n\n仓库名用这个最标准:\n\n```bash\nnetdcy/homebrew-tap\n```\n\nHomebrew 会把它识别成 tap `netdcy/tap`。\n\n**2. 在 tap 仓库里放 cask 文件**\n\n路径:\n\n```text\nCasks/flowvision.rb\n```\n\n内容可以直接用你这个版本:\n\n```ruby\ncask \"flowvision\" do\n version \"1.7.0\"\n sha256 \"9200dcaa1e4a89d64dba6788f190713e45b92c667aaa81ec10619dde5806dc92\"\n\n url \"https://github.com/netdcy/FlowVision/releases/download/v#{version}/FlowVision-Release-2026-04-19-v2.dmg\",\n verified: \"github.com/netdcy/FlowVision/\"\n name \"FlowVision\"\n desc \"Waterfall-style image viewer for macOS\"\n homepage \"https://github.com/netdcy/FlowVision\"\n\n depends_on macos: \"\u003e= :big_sur\"\n\n app \"FlowVision.app\"\nend\n```\n\n你这里有两个前提要满足:\n\n1. 你的 `FlowVision` 仓库里必须有一个 GitHub Release,tag 最好叫 `v1.7.0`\n2. 这个 Release 里必须上传 `FlowVision-Release-2026-04-19-v2.dmg`\n\n你现在这个 dmg 的 SHA256 我已经帮你算过了,就是上面这串。\n\n**3. 用户安装命令**\n\n发布后,安装命令应该写成:\n\n```bash\nbrew tap netdcy/tap\nbrew install --cask flowvision\n```\n\n或者一步到位:\n\n```bash\nbrew install --cask netdcy/tap/flowvision\n```\n\n升级:\n\n```bash\nbrew upgrade --cask flowvision\n```\n\n**4. 你 README_zh.md 现在这段要改**\n\n你现在写的是:\n\n```bash\nbrew install flowvision\n```\n\n这只有在进 `homebrew-cask` 官方仓库后才成立。对你自己的 GitHub 项目,应该改成:\n\n```md\n### Homebrew 方式安装\n\n首次安装\n```bash\nbrew tap netdcy/tap\nbrew install --cask flowvision\n```\n\n或直接:\n```bash\nbrew install --cask netdcy/tap/flowvision\n```\n\n版本升级\n```bash\nbrew update\nbrew upgrade --cask flowvision\n```\n```\n\n**5. 一个实际建议**\n\n你当前发布文件名是按日期命名的,这能用,但不利于后续维护。更适合改成这种:\n\n```text\nFlowVision-1.7.0.dmg\n```\n\n这样 cask 会更清晰:\n\n```ruby\nurl \"https://github.com/netdcy/FlowVision/releases/download/v#{version}/FlowVision-#{version}.dmg\"\n```\n\n后面每次发版只改两处:\n\n1. `version`\n2. `sha256`\n\n如果你愿意,我下一步可以直接帮你产出:\n\n1. `homebrew-tap` 仓库里的完整 `Casks/flowvision.rb`\n2. 你这个仓库里可直接替换的 `README_zh.md` 安装段落\n3. 一个自动发 GitHub Release 并更新 cask 的 GitHub Actions 方案","timestamp":"2026-04-21T04:42:26.44946Z"}
diff --git a/.mindfs/sessions/session-list.db b/.mindfs/sessions/session-list.db
new file mode 100644
index 00000000..949d29e6
Binary files /dev/null and b/.mindfs/sessions/session-list.db differ
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..1b6c5215 100644
--- a/FlowVision.xcodeproj/project.pbxproj
+++ b/FlowVision.xcodeproj/project.pbxproj
@@ -72,6 +72,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 */; };
@@ -175,6 +176,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 = ""; };
@@ -251,6 +253,7 @@
F7FAE0002F6BA00000FAE000 /* FavoritesPopoverViewController.swift */,
F78960F12BDCC26B00C2571B /* LargeImageView.swift */,
F73865D42C37B9B800837FE4 /* Layout.swift */,
+ B513CF896032F495D8769284 /* MPVPlayerBackend.swift */,
F7AA00002F9E000000AA0001 /* VideoPlayerControlsView.swift */,
);
path = Views;
@@ -485,6 +488,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 */,
@@ -730,6 +734,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 20260422;
+ DEVELOPMENT_TEAM = M6WQA48PJ2;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = FlowVision/Info.plist;
@@ -763,7 +768,7 @@
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 20260422;
- DEVELOPMENT_TEAM = M9PR3WG2FN;
+ DEVELOPMENT_TEAM = M6WQA48PJ2;
ENABLE_HARDENED_RUNTIME = YES;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = FlowVision/Info.plist;
diff --git a/FlowVision/.DS_Store b/FlowVision/.DS_Store
new file mode 100644
index 00000000..27886a7f
Binary files /dev/null and b/FlowVision/.DS_Store differ
diff --git a/FlowVision/Resources/.DS_Store b/FlowVision/Resources/.DS_Store
new file mode 100644
index 00000000..dbec438d
Binary files /dev/null and b/FlowVision/Resources/.DS_Store differ
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..b4e8f30a 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" : {
@@ -5886,6 +5895,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 +6670,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 +7561,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 +8530,9 @@
}
}
},
+ "Drag to select video crop area" : {
+ "comment" : "拖动选择视频裁剪区域"
+ },
"Edit Mode" : {
"comment" : "编辑模式",
"localizations" : {
@@ -9310,6 +9389,9 @@
}
}
},
+ "Encrypt ZIP" : {
+ "comment" : "加密压缩 ZIP"
+ },
"Enlarge the Thumbnails" : {
"comment" : "放大缩略图",
"localizations" : {
@@ -9738,6 +9820,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 +13461,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 +13599,7 @@
}
},
"Favorites" : {
- "comment" : "收藏夹",
+ "comment" : "收藏\n收藏夹",
"localizations" : {
"ar" : {
"stringUnit" : {
@@ -15837,6 +15952,9 @@
}
}
},
+ "Frame Saved" : {
+ "comment" : "视频帧已保存"
+ },
"gen-thumb-info" : {
"comment" : "对于高清缩略图的说明...",
"localizations" : {
@@ -17014,6 +17132,9 @@
}
}
},
+ "Height" : {
+ "comment" : "高度"
+ },
"Highlighter" : {
"comment" : "荧光笔",
"localizations" : {
@@ -17121,6 +17242,9 @@
}
}
},
+ "History" : {
+ "comment" : "历史"
+ },
"Image" : {
"comment" : "图像",
"localizations" : {
@@ -21722,6 +21846,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 +24637,9 @@
}
}
},
+ "Password cannot be empty." : {
+ "comment" : "密码不能为空。"
+ },
"Paste" : {
"comment" : "粘贴",
"localizations" : {
@@ -24825,6 +24961,9 @@
}
}
},
+ "Photo Folder 1 does not exist or is not a folder." : {
+ "comment" : "图片文件夹1不存在或不是文件夹。"
+ },
"Pin Window" : {
"comment" : "置顶",
"localizations" : {
@@ -25146,6 +25285,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 +25401,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 +26278,9 @@
}
}
},
+ "Profile Switching:" : {
+ "comment" : "配置切换:"
+ },
"qrcode-recog-fail" : {
"comment" : "未能识别到二维码",
"localizations" : {
@@ -26323,6 +26495,9 @@
}
}
},
+ "Quick Rename" : {
+ "comment" : "快速重命名"
+ },
"Quick Search" : {
"comment" : "快速搜索",
"localizations" : {
@@ -28784,6 +28959,9 @@
}
}
},
+ "Remove from Favorites" : {
+ "comment" : "取消收藏"
+ },
"Rename" : {
"comment" : "重命名",
"localizations" : {
@@ -29319,6 +29497,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 +29731,9 @@
}
}
},
+ "Rotate" : {
+ "comment" : "旋转"
+ },
"Rotate %d°" : {
"comment" : "(视频)旋转%d°",
"localizations" : {
@@ -29640,6 +29841,9 @@
}
}
},
+ "Rotate 180°" : {
+ "comment" : "旋转180°"
+ },
"Rotate Clockwise" : {
"comment" : "顺时针旋转",
"localizations" : {
@@ -29747,6 +29951,9 @@
}
}
},
+ "Rotate Clockwise 90°" : {
+ "comment" : "顺时针旋转90°"
+ },
"Rotate Counterclockwise" : {
"comment" : "逆时针旋转",
"localizations" : {
@@ -29854,6 +30061,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 +41001,9 @@
}
}
},
+ "Use the check button to crop, drag again to adjust" : {
+ "comment" : "点击对号裁剪,重新拖动可调整"
+ },
"Video" : {
"comment" : "视频",
"localizations" : {
@@ -40875,6 +41111,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 +43257,12 @@
}
}
},
+ "Virtual entries are not supported for this operation." : {
+ "comment" : "该操作不支持虚拟目录或压缩包内虚拟条目。"
+ },
+ "Virtual entries cannot be compressed." : {
+ "comment" : "虚拟目录或压缩包内虚拟条目不支持压缩。"
+ },
"Volume" : {
"comment" : "音量",
"localizations" : {
@@ -43229,6 +43477,9 @@
}
}
},
+ "Width" : {
+ "comment" : "宽度"
+ },
"Window Title" : {
"comment" : "窗口标题",
"localizations" : {
@@ -43763,6 +44014,51 @@
}
}
}
+ },
+ "使用默认密码加密压缩" : {
+ "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"
diff --git a/FlowVision/Sources/.DS_Store b/FlowVision/Sources/.DS_Store
new file mode 100644
index 00000000..5f4a2197
Binary files /dev/null and b/FlowVision/Sources/.DS_Store differ
diff --git a/FlowVision/Sources/AppDelegate.swift b/FlowVision/Sources/AppDelegate.swift
index ab821dab..cb8d5be7 100644
--- a/FlowVision/Sources/AppDelegate.swift
+++ b/FlowVision/Sources/AppDelegate.swift
@@ -193,6 +193,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 +220,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
}
@@ -533,6 +566,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 +658,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 +830,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 +857,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 +928,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 +1285,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..3323514b 100644
--- a/FlowVision/Sources/Common/Common.swift
+++ b/FlowVision/Sources/Common/Common.swift
@@ -129,6 +129,244 @@ 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
+}
+
+func decodeBsdtarEscapedPath(_ text: String) -> String {
+ let bytes = bsdtarEscapedPathBytes(text)
+ if let decoded = String(bytes: bytes, encoding: .utf8) {
+ return decoded
+ }
+ if let decoded = String(data: Data(bytes), encoding: .shiftJIS) {
+ 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] = []
+ let decoded = decodeBsdtarEscapedPath(entryPath)
+ candidatePaths.append(decoded)
+ if decoded != entryPath {
+ candidatePaths.append(entryPath)
+ } else {
+ let escaped = encodeBsdtarEscapedPath(entryPath)
+ if escaped != entryPath {
+ candidatePaths.append(escaped)
+ }
+ let shiftJISEscaped = encodeBsdtarEscapedPath(entryPath, encoding: .shiftJIS)
+ if shiftJISEscaped != entryPath && !candidatePaths.contains(shiftJISEscaped) {
+ candidatePaths.append(shiftJISEscaped)
+ }
+ }
+
+ 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 +569,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..6092d07d 100644
--- a/FlowVision/Sources/Common/DataModel.swift
+++ b/FlowVision/Sources/Common/DataModel.swift
@@ -636,12 +636,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 +675,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 +737,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 +764,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 +775,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 +831,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 +859,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..6938a77c 100644
--- a/FlowVision/Sources/Common/GlobalVariable.swift
+++ b/FlowVision/Sources/Common/GlobalVariable.swift
@@ -89,6 +89,7 @@ class GlobalVar{
var usePinyinInitialSearch = false
var videoPlayRememberPosition = false
var videoPlaySequentialPlay = false
+ var preferIINAForExternalVideoPlayer = true
var useInternalPlayer = true {
didSet {
useInternalPlayerCheckbox?.state = useInternalPlayer ? .on : .off
@@ -104,6 +105,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 +218,3 @@ func getSystemVolumeName() -> String? {
return nil
}
}
-
diff --git a/FlowVision/Sources/Common/ImageProcess.swift b/FlowVision/Sources/Common/ImageProcess.swift
index ed2d7acb..878836c2 100644
--- a/FlowVision/Sources/Common/ImageProcess.swift
+++ b/FlowVision/Sources/Common/ImageProcess.swift
@@ -9,6 +9,27 @@ 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)
+}
+
extension NSImage {
func rotated(by degrees: CGFloat) -> NSImage {
if degrees == 0 { return self }
@@ -919,7 +940,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 +958,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 +1023,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 +1107,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 +1121,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 +1149,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 +1763,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 +2129,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 +2328,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/VideoProcess.swift b/FlowVision/Sources/Common/VideoProcess.swift
index d179fcfc..c4a7bef4 100644
--- a/FlowVision/Sources/Common/VideoProcess.swift
+++ b/FlowVision/Sources/Common/VideoProcess.swift
@@ -15,9 +15,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
@@ -83,4 +84,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/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..cf423c5f 100755
--- a/FlowVision/Sources/SettingsViews/Base.lproj/CustomSettingsViewController.xib
+++ b/FlowVision/Sources/SettingsViews/Base.lproj/CustomSettingsViewController.xib
@@ -277,7 +277,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.
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..dfab72bb 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\" 等視頻格式,其他格式仍需使用外部播放器播放"
}
}
diff --git a/FlowVision/Sources/ViewController.swift b/FlowVision/Sources/ViewController.swift
index f98f8bef..d5fd3cdf 100644
--- a/FlowVision/Sources/ViewController.swift
+++ b/FlowVision/Sources/ViewController.swift
@@ -412,6 +412,7 @@ class ViewController: NSViewController, NSSplitViewDelegate, NSSearchFieldDelega
var dirURLCache: [URL] = []
var dirURLCacheParameters: Any = []
+ var archiveImageEntryCache: [String: [String]] = [:]
// 加载进度条
// Loading progress bar
@@ -2169,7 +2170,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..ce30b3a6 100644
--- a/FlowVision/Sources/ViewControllerExtension/FileOperation.swift
+++ b/FlowVision/Sources/ViewControllerExtension/FileOperation.swift
@@ -7,16 +7,656 @@ import Foundation
import Cocoa
import AVFoundation
import DiskArbitration
+import ImageIO
+
+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 ""
+ }
+ }
+}
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
+ }
+
+ @discardableResult
+ private func executeFileRenameMappings(
+ _ mappings: [FileRenameMapping],
+ actionName: String,
+ registerUndo: Bool = true,
+ locateTargets: [URL]? = 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.lowercased() == targetPath {
+ 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.lowercased() == mapping.to.path.lowercased() {
+ 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) })
+ 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)
+ )
+ }
+ 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 ifRefresh {
+ scheduledRefresh()
+ }
+
+ return true
+ }
+
@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 +665,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 +676,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 +693,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 +710,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 +727,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 +737,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 +748,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 +774,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 +798,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 +808,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 +819,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 +851,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()
@@ -228,33 +868,33 @@ extension ViewController {
}
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
@@ -280,13 +920,13 @@ extension ViewController {
// }
// }
// }
-
+
// 备份剪贴板内容的函数
// 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 {
@@ -294,56 +934,663 @@ extension ViewController {
backupItem[type.rawValue] = data
}
}
- backupItems.append(backupItem)
+ 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)
+ if selectedURLs.contains(where: { isVirtualArchiveEntryPath($0.absoluteString) }) {
+ var failedItems: [String] = []
+ var successCount = 0
+ for srcURL in selectedURLs {
+ if isVirtualArchiveEntryPath(srcURL.absoluteString) {
+ guard let parsed = parseVirtualArchivePath(srcURL.absoluteString),
+ let entryPath = parsed.entryPath,
+ let data = getArchiveEntryData(archiveURL: parsed.archiveURL, entryPath: entryPath) else {
+ failedItems.append(srcURL.lastPathComponent.removingPercentEncoding ?? srcURL.lastPathComponent)
+ continue
+ }
+ let fileName = URL(fileURLWithPath: entryPath).lastPathComponent
+ let targetURL = getUniqueDestinationURL(for: targetFolderURL.appendingPathComponent(fileName), isInPlace: false)
+ do {
+ try data.write(to: targetURL, options: .atomic)
+ successCount += 1
+ } catch {
+ log("Copy archive entry failed: \(error)", level: .error)
+ failedItems.append(fileName)
+ }
+ } else {
+ let targetURL = getUniqueDestinationURL(for: targetFolderURL.appendingPathComponent(srcURL.lastPathComponent), isInPlace: false)
+ do {
+ try FileManager.default.copyItem(at: srcURL, to: targetURL)
+ successCount += 1
+ } catch {
+ log("Copy file failed: \(error)", level: .error)
+ failedItems.append(srcURL.lastPathComponent)
+ }
+ }
+ }
+
+ if successCount > 0 {
+ publicVar.fileChangedCount += successCount
+ scheduledRefresh()
+ showPhotoFolderCopyToast(selectedURLs: selectedURLs, targetFolderURL: targetFolderURL)
+ }
+ if !failedItems.isEmpty {
+ let preview = failedItems.prefix(3).joined(separator: ", ")
+ showAlert(message: String(format: NSLocalizedString("Failed to copy some files: %@", comment: "部分文件复制失败:%@"), preview))
+ }
+ return
+ }
+
+ let backupItems = backupPasteboard()
+ let pasteboard = NSPasteboard.general
+ pasteboard.clearContents()
+ pasteboard.writeObjects(selectedURLs as [NSPasteboardWriting])
+ globalVar.isCutMode = false
+ clearCutItemsDimEffect()
+ handlePaste(targetURL: targetFolderURL)
+ showPhotoFolderCopyToast(selectedURLs: selectedURLs, targetFolderURL: targetFolderURL)
+ restorePasteboard(items: backupItems)
+ }
+
+ 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
+ }
+
+ @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
+ }
+ return handleCompress(urls: urls, mode: .encryptedZip(password: password), deleteOriginal: deleteOriginal)
+ }
+ return handleCompress(urls: urls, mode: .plainZip, deleteOriginal: deleteOriginal)
+ }
+
+ 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)
+ }
+ }
+
+ 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
+ }
+
+ 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
+ }
+
+ 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: "抓取当前视频帧失败。"))
+ }
+ }
+
+ @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)
+ }
+ }
}
-
- 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])
+ 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 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 handlePaste(targetURL: URL? = nil, pasteboard: NSPasteboard = NSPasteboard.general) {
// 如果是剪切模式,执行移动操作而非复制
// If in cut mode, perform move operation instead of copy
@@ -353,9 +1600,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,12 +1613,12 @@ 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) {
@@ -394,7 +1641,7 @@ extension ViewController {
}
fileNames.insert(fileName)
}
-
+
// 如果有同名文件,弹窗询问是否继续
// If there are files with same name, show dialog asking whether to continue
if hasDuplicates {
@@ -407,7 +1654,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 +1674,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 +1699,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 +1723,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 +1740,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 +1756,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 +1873,38 @@ 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) {
-
+
// 重置剪切模式,防止直接调用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,12 +1915,12 @@ 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) {
@@ -696,7 +1943,7 @@ extension ViewController {
}
fileNames.insert(fileName)
}
-
+
// 如果有同名文件,弹窗询问是否继续
// If there are files with same name, show dialog asking whether to continue
if hasDuplicates {
@@ -709,7 +1956,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 +1969,24 @@ 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)
-
+
// 在文件操作期间抑制文件系统监控触发的刷新,操作完成后主动刷新
// Suppress FS watcher refreshes during file operations, refresh explicitly after completion
publicVar.isInFileOperation = true
@@ -759,7 +2006,7 @@ extension ViewController {
pasteboard.clearContents()
}
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()
@@ -772,20 +2019,20 @@ extension ViewController {
EnhancedIndex.handleFilesMoved(indexMovePairs)
}
}
-
+
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
@@ -805,7 +2052,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 +2169,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 +2224,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 +2232,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 +2264,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 +2295,7 @@ extension ViewController {
}
}
}
-
+
EnhancedIndex.handleFilesDeleted(urlsToDelete.map { $0.path })
// 文件更改计数
@@ -1058,7 +2305,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 +2313,7 @@ extension ViewController {
if ifRefresh {
scheduledRefresh()
}
-
+
} else {
log("File to delete does not exist")
}
@@ -1078,7 +2325,7 @@ extension ViewController {
return false
}
}
-
+
enum ReplaceDialogUserChoice {
case replace
case replaceAll
@@ -1097,7 +2344,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 +2354,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 +2365,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 +2406,7 @@ extension ViewController {
}
}
}
-
+
/// Tracks user choices across recursive merge operations so "apply to all" persists.
class MergeConflictState {
var shouldReplaceAll = false
@@ -1167,17 +2414,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 +2434,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 +2554,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 +2574,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 +2692,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 +2720,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 +2731,7 @@ extension ViewController {
textFieldCell.isScrollable = true
}
alert.accessoryView = inputTextField
-
+
// 显示对话框
// Show dialog
let StoreIsKeyEventEnabled = publicVar.isKeyEventEnabled
@@ -1494,7 +2741,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 +2757,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 +2771,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 +2805,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 +2841,110 @@ 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)
+ 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
+ }
- // 手动刷新
- // 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()
-
- }
- if ifRefresh {
- scheduledRefresh()
+ func handleQuickRenameInCurrentFolder() -> Bool {
+ 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 originalPathSet = Set(urls.map { $0.path.lowercased() })
+ var plannedPathSet = Set()
+ var finalNames: [(originalUrl: URL, finalUrl: URL)] = []
+
+ 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)"
+ }
+
+ 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 candidatePathLower = candidateURL.path.lowercased()
+
+ let existsAndNotInOriginalSet =
+ FileManager.default.fileExists(atPath: candidateURL.path) &&
+ !originalPathSet.contains(candidatePathLower)
+ let isPlannedConflict = plannedPathSet.contains(candidatePathLower)
+
+ if !existsAndNotInOriginalSet && !isPlannedConflict {
+ finalUrl = candidateURL
+ plannedPathSet.insert(candidatePathLower)
+ break
}
-
- return allSuccess
+ suffix += 1
}
+
+ finalNames.append((originalUrl: originalUrl, finalUrl: finalUrl))
}
- return false
+
+ let operationLog = "[QuickRename] \(folderName) -> \(rule)"
+ globalVar.operationLogs.append(operationLog)
+
+ let mappings = finalNames.map { FileRenameMapping(from: $0.originalUrl, to: $0.finalUrl) }
+ return executeFileRenameMappings(
+ mappings,
+ actionName: NSLocalizedString("快速重命名", comment: "quick rename undo")
+ )
}
-
+
func applyCutItemsDimEffect() {
for window in NSApp.windows {
guard let vc = window.contentViewController as? ViewController else { continue }
@@ -1689,7 +2956,7 @@ extension ViewController {
updateOutlineViewCutDimEffect(vc.outlineView)
}
}
-
+
func clearCutItemsDimEffect() {
let hadCutItems = !globalVar.cutItemPaths.isEmpty
globalVar.cutItemPaths.removeAll()
@@ -1705,7 +2972,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..121a36fd 100644
--- a/FlowVision/Sources/ViewControllerExtension/FileSystem.swift
+++ b/FlowVision/Sources/ViewControllerExtension/FileSystem.swift
@@ -202,6 +202,130 @@ 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)
+ 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
@@ -270,11 +394,13 @@ 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 +477,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 +506,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 +526,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)
}
// 不将替身文件统计为图像或视频
@@ -544,34 +680,40 @@ extension ViewController {
// 文件在前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
@@ -848,7 +990,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: "目录不存在"))
}
}
@@ -1045,7 +1187,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 +1273,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..eb471c00 100644
--- a/FlowVision/Sources/ViewControllerExtension/KeyShortcut.swift
+++ b/FlowVision/Sources/ViewControllerExtension/KeyShortcut.swift
@@ -8,6 +8,115 @@ import Cocoa
extension ViewController {
+ private func isConfiguredFolderCopyShortcutTriggered(_ configuredShortcut: String, characters: String, specialKey: NSEvent.SpecialKey, noModifierKey: Bool) -> Bool {
+ guard noModifierKey else { return false }
+
+ let shortcut = 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?
{
// 检查事件的窗口是否是当前窗口,如果不是、也非弹窗状态,就不处理,事件继续传递
@@ -44,6 +153,14 @@ extension ViewController {
let characters = (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 +219,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 +273,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 +505,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 +529,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 +581,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 +606,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 +1142,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..8a8c34b6 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 {
@@ -968,13 +978,21 @@ extension ViewController {
if let animateImage = getAnimateImage(url: url, rotate: rotate) {
largeImage = animateImage
} else {
- largeImage = NSImage(contentsOf: url)?.rotated(by: CGFloat(-90*rotate))
+ if let data = getArchiveEntryDataIfNeeded(url: url) {
+ largeImage = NSImage(data: data)?.rotated(by: CGFloat(-90*rotate))
+ } else {
+ largeImage = NSImage(contentsOf: url)?.rotated(by: CGFloat(-90*rotate))
+ }
}
}else{
largeImage = getResizedImage(url: url, size: largeSize, rotate: rotate, isRawUseEmbeddedThumb: publicVar.isRawUseEmbeddedThumb)
if largeImage == nil {
lastResizeFailed = true
- largeImage = NSImage(contentsOf: url)?.rotated(by: CGFloat(-90*rotate))
+ if let data = getArchiveEntryDataIfNeeded(url: url) {
+ largeImage = NSImage(data: data)?.rotated(by: CGFloat(-90*rotate))
+ } else {
+ largeImage = NSImage(contentsOf: url)?.rotated(by: CGFloat(-90*rotate))
+ }
}
}
}
diff --git a/FlowVision/Sources/Views/CoreAreaView.swift b/FlowVision/Sources/Views/CoreAreaView.swift
index e2dfb89f..6f8fb1be 100644
--- a/FlowVision/Sources/Views/CoreAreaView.swift
+++ b/FlowVision/Sources/Views/CoreAreaView.swift
@@ -15,6 +15,11 @@ class CoreAreaView: NSView {
private var scanProgressLabel: NSTextField?
var onScanCancel: (() -> 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..1190ed65 100644
--- a/FlowVision/Sources/Views/CustomCollectionViewItem.swift
+++ b/FlowVision/Sources/Views/CustomCollectionViewItem.swift
@@ -625,7 +625,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:") {
@@ -1063,6 +1063,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 +1074,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 +1107,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,11 +1134,46 @@ 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())
let actionItemRename = menu.addItem(withTitle: NSLocalizedString("Rename", comment: "重命名"), action: #selector(actRename), keyEquivalent: "r")
@@ -1147,17 +1184,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 +1231,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 +1296,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,
@@ -1352,6 +1440,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..ead2d569 100644
--- a/FlowVision/Sources/Views/CustomCollectionViewManager.swift
+++ b/FlowVision/Sources/Views/CustomCollectionViewManager.swift
@@ -7,14 +7,14 @@ import Foundation
import Cocoa
class CustomCollectionViewManager: NSObject, NSCollectionViewDataSource, NSCollectionViewDelegate, NSCollectionViewDelegateFlowLayout {
-
+
var fileDB: DatabaseModel
var lastSelectedIndexPath: IndexPath?
-
+
init(fileDB: DatabaseModel) {
self.fileDB = fileDB
}
-
+
func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int {
fileDB.lock()
defer{fileDB.unlock()}
@@ -23,7 +23,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 +32,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 +54,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 +72,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,7 +82,7 @@ class CustomCollectionViewManager: NSObject, NSCollectionViewDataSource, NSColle
}
return DEFAULT_SIZE
}
-
+
func collectionView(_ collectionView: NSCollectionView, pasteboardWriterForItemAt indexPath: IndexPath) -> NSPasteboardWriting? {
fileDB.lock()
defer{fileDB.unlock()}
@@ -91,17 +93,17 @@ class CustomCollectionViewManager: NSObject, NSCollectionViewDataSource, NSColle
}
return pasteboardItem
}
-
+
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 +113,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..ad112969 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,7 +157,21 @@ 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 actionItemRename = menu.addItem(withTitle: NSLocalizedString("Rename", comment: "重命名"), action: #selector(actRename), keyEquivalent: "r")
@@ -391,6 +413,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..2a90d8c9 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,48 @@ class LargeImageView: NSView {
updateVideoMetadata(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
if let timeRange = getCommonTimeRange(url: url) {
playerItem = AVPlayerItem(url: url)
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 +1049,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 +1142,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 +1237,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 +1268,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 +1280,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 +1335,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 +1344,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 +1369,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 +1391,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 +1405,7 @@ class LargeImageView: NSView {
// 设置新音量
// Set new volume
- player.volume = newVolume
+ videoVolume = newVolume
// 显示音量信息
// Display volume information
@@ -1135,8 +1422,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 +1432,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 +1463,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 +1489,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 +1501,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 +1958,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 +2002,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 +2037,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 +2130,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 +2236,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 +2341,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 +2454,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 +2820,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..e1d28dd7
--- /dev/null
+++ b/FlowVision/Sources/Views/MPVPlayerBackend.swift
@@ -0,0 +1,662 @@
+//
+// 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 let lib: LibMPV
+ private var handle: OpaquePointer?
+ private var renderContext: OpaquePointer?
+ private weak var renderView: FlowMPVVideoView?
+ private weak var renderLayer: MPVRenderLayer?
+ 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("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
+ }
+ 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) }
+
+ 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
+
+ if let renderContext {
+ lib.renderContextSetUpdateCallback(renderContext, nil, nil)
+ lib.renderContextFree(renderContext)
+ self.renderContext = nil
+ }
+ renderView?.detach()
+
+ 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() {
+ guard let renderContext else { return }
+ lib.renderContextReportSwap(renderContext)
+ }
+
+ func shouldRenderUpdateFrame() -> Bool {
+ guard let renderContext else { return false }
+ return (lib.renderContextUpdate(renderContext) & mpvRenderUpdateFrame) != 0
+ }
+
+ fileprivate func render(params: inout [MPVRenderParam]) {
+ guard let renderContext, let layer = renderLayer else { return }
+ CGLLockContext(layer.cglContext)
+ CGLSetCurrentContext(layer.cglContext)
+ _ = params.withUnsafeMutableBufferPointer { buffer in
+ lib.renderContextRender(renderContext, UnsafeMutableRawPointer(buffer.baseAddress))
+ }
+ CGLUnlockContext(layer.cglContext)
+ }
+
+ 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 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..b3945617 100644
--- a/README.md
+++ b/README.md
@@ -1,39 +1,78 @@
-
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
+- No Internet connection required
### Homebrew Install
@@ -47,23 +86,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 +225,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 +253,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.
-
+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.
\ No newline at end of file
diff --git a/README_zh.md b/README_zh.md
index 5ada75d7..3d979b17 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,67 @@
### 浅色模式

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

-## 应用特点:
-
- - 自适应布局模式、浅色/深色模式
-
- - 方便的文件管理(操作类似 Finder)
-
- - 右键手势、快速查找上一个/下一个有图片/视频的文件夹
-
- - 针对目录下大量图片情况的性能优化
-
- - 高质量的缩放(减轻摩尔纹等问题)
-
- - 支持视频播放
-
- - 支持HDR显示
-
- - 支持递归模式
+## 功能特性
+
+### 核心功能
+- 多种自适应布局模式(两端对齐、瀑布流、网格、详情列表)
+- 浅色/深色模式自动适配
+- 便捷的文件管理(操作类似 Finder)
+- 右键手势快速文件夹导航
+- 大量图片目录的性能优化
+- 高质量缩放(减轻摩尔纹等问题)
+- HDR 显示支持
+- 递归浏览模式
+
+### 图片功能
+- 支持 40+ 种图片格式,包括 RAW 文件
+- 双击打开/关闭大图查看
+- 鼠标手势缩放(按住右键/左键 + 滚轮)
+- 长按左键切换 100% 缩放
+- 按右键切换适应视图
+- 图片旋转和镜像翻转
+- OCR 文字识别
+- 二维码检测
+- EXIF 信息显示
+- 图片编辑模式
+
+### 视频功能
+- 内置视频播放器(FFmpeg 支持)
+- 方向键视频定位
+- A-B 循环播放(用 `,` 和 `.` 设置循环点)
+- 记忆播放位置
+- 顺序播放模式
+- 视频截图
+- 自动播放可见视频选项
+
+### 文件管理
+- 复制/移动/删除/重命名操作
+- 快速搜索文件名(支持拼音搜索)
+- 自定义规则快速重命名
+- 自定义快捷键复制到指定文件夹
+- Finder 标签和评分支持
+- 压缩包文件支持,可提取内部图片
+- 新建文件夹
+
+### 布局与配置
+- 多种布局类型可切换
+- 9 个可自定义配置槽位
+- 缩略图大小调节
+- 多种排序方式(名称、日期、大小、EXIF、随机等)
## 安装使用
### 系统需求
- - macOS 11.0+
+- macOS 11.0+
### 隐私与安全性
- - 开源软件
- - 无网络请求
+- 开源软件
+- 无网络请求
### Homebrew 方式安装
@@ -54,24 +84,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 +223,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 +250,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) 文件。
\ No newline at end of file
diff --git a/build_dmg.sh b/build_dmg.sh
new file mode 100755
index 00000000..b1c65be0
--- /dev/null
+++ b/build_dmg.sh
@@ -0,0 +1,96 @@
+#!/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 [[ "$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/dist/FlowVision-macOS.dmg b/dist/FlowVision-macOS.dmg
new file mode 100644
index 00000000..0a4095f9
Binary files /dev/null and b/dist/FlowVision-macOS.dmg differ
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..07eb0290
--- /dev/null
+++ b/public/doc/ARCHITECTURE.md
@@ -0,0 +1,298 @@
+# FlowVision 项目架构文档
+
+## 项目概述
+
+FlowVision 是一款 macOS 瀑布流风格图片查看器,支持图片和视频浏览,具有以下特性:
+- 自适应布局模式,支持明暗主题
+- 便捷的文件管理(类似 Finder)
+- 右键手势快速导航
+- 大量图片目录的性能优化
+- 高质量缩放
+- 视频播放支持
+- HDR 显示支持
+- 递归浏览模式
+
+## 系统要求
+
+- 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 刷新
+```
+
+## 支持的文件格式
+
+### 图片格式
+- 常见格式: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-04-20*
\ No newline at end of file
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.