diff --git a/.github/workflows/sync-release-to-gitcode.yml b/.github/workflows/sync-release-to-gitcode.yml new file mode 100644 index 00000000..3aedf707 --- /dev/null +++ b/.github/workflows/sync-release-to-gitcode.yml @@ -0,0 +1,447 @@ +name: Sync Release to GitCode + +on: + release: + types: [published, edited] + workflow_dispatch: + inputs: + tag_name: + description: 'Tag name to sync (e.g., v1.0.0)' + required: true + type: string + release_name: + description: 'Release name' + required: false + type: string + release_body: + description: 'Release description/body' + required: false + type: string + prerelease: + description: 'Is this a prerelease?' + required: false + type: boolean + default: false + draft: + description: 'Is this a draft release?' + required: false + type: boolean + default: false + test_mode: + description: 'Test mode (dry run - no actual sync to GitCode)' + required: false + type: boolean + default: false + +env: + GITCODE_API_BASE: https://gitcode.com/api/v5 + GITCODE_OWNER: ${{ vars.GITCODE_OWNER || 'mkdir700' }} + GITCODE_REPO: EchoPlayer + +jobs: + sync-to-gitcode: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Fetch all history for all branches and tags + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Get release information + id: release + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + # Manual trigger - use inputs + echo "🔧 Manual trigger detected, using workflow inputs" + echo "tag_name=${{ github.event.inputs.tag_name }}" >> $GITHUB_OUTPUT + echo "release_name=${{ github.event.inputs.release_name || github.event.inputs.tag_name }}" >> $GITHUB_OUTPUT + echo "release_body<> $GITHUB_OUTPUT + echo "${{ github.event.inputs.release_body || 'Test release created via manual trigger' }}" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "prerelease=${{ github.event.inputs.prerelease }}" >> $GITHUB_OUTPUT + echo "draft=${{ github.event.inputs.draft }}" >> $GITHUB_OUTPUT + echo "test_mode=${{ github.event.inputs.test_mode }}" >> $GITHUB_OUTPUT + else + # Automatic trigger - use release event data + echo "🚀 Release event detected, using release data" + echo "tag_name=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT + echo "release_name=${{ github.event.release.name }}" >> $GITHUB_OUTPUT + echo "release_body<> $GITHUB_OUTPUT + echo "${{ github.event.release.body }}" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + echo "prerelease=${{ github.event.release.prerelease }}" >> $GITHUB_OUTPUT + echo "draft=${{ github.event.release.draft }}" >> $GITHUB_OUTPUT + echo "test_mode=false" >> $GITHUB_OUTPUT + fi + + - name: Sync repository to GitCode + if: steps.release.outputs.test_mode != 'true' + run: | + echo "🔄 Syncing repository to GitCode using HTTPS..." + + # Configure git with token authentication + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + + # Construct GitCode repository URL with token authentication + GITCODE_REPO_URL="https://oauth2:${{ secrets.GITCODE_ACCESS_TOKEN }}@gitcode.com/$GITCODE_OWNER/$GITCODE_REPO.git" + + echo "Repository: $GITCODE_OWNER/$GITCODE_REPO" + + # Add GitCode remote (remove if exists) + if git remote | grep -q "gitcode"; then + echo "Removing existing gitcode remote" + git remote remove gitcode + fi + + echo "Adding GitCode remote with HTTPS authentication" + git remote add gitcode "$GITCODE_REPO_URL" + + echo "📤 Force pushing branches to GitCode..." + + # Show available branches + echo "Available branches:" + git branch -a | grep -E "(main|dev|alpha|beta)" || echo "Target branches not found" + + # Force push main branches to GitCode + for branch in main dev alpha beta; do + if git show-ref --verify --quiet refs/heads/$branch || git show-ref --verify --quiet refs/remotes/origin/$branch; then + echo "Pushing branch: $branch" + if git show-ref --verify --quiet refs/heads/$branch; then + git push --force gitcode $branch:$branch || { + echo "❌ Failed to push local branch $branch" + exit 1 + } + else + git push --force gitcode origin/$branch:$branch || { + echo "❌ Failed to push remote branch $branch" + exit 1 + } + fi + echo "✅ Successfully pushed branch: $branch" + else + echo "⚠️ Branch $branch not found, skipping" + fi + done + + echo "🏷️ Pushing all tags to GitCode..." + echo "Available tags (last 10):" + git tag | tail -10 || echo "No tags found" + + git push --force gitcode --tags || { + echo "❌ Failed to push tags" + exit 1 + } + + echo "✅ Repository sync completed successfully" + + - name: Test mode - Skip repository sync + if: steps.release.outputs.test_mode == 'true' + run: | + echo "🧪 Test mode enabled - skipping repository sync to GitCode" + echo "Would sync the following branches: main, dev, alpha, beta" + echo "Would force push all tags to GitCode" + echo "This would ensure tag ${{ steps.release.outputs.tag_name }} exists before creating release" + + - name: Download release assets + id: download-assets + run: | + mkdir -p ./release-assets + + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + # Manual trigger - fetch release data from GitHub API + echo "📦 Fetching release assets for tag: ${{ steps.release.outputs.tag_name }}" + + release_response=$(curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + "https://api.github.com/repos/${{ github.repository }}/releases/tags/${{ steps.release.outputs.tag_name }}") + + if [ "$(echo "$release_response" | jq -r '.message // empty')" = "Not Found" ]; then + echo "⚠️ Release not found for tag: ${{ steps.release.outputs.tag_name }}" + assets_json='[]' + else + assets_json=$(echo "$release_response" | jq '.assets') + fi + else + # Automatic trigger - use event data + assets_json='${{ toJson(github.event.release.assets) }}' + fi + + echo "Assets to download:" + echo "$assets_json" | jq -r '.[] | "\(.name) - \(.browser_download_url)"' + + asset_files="" + if [ "$(echo "$assets_json" | jq 'length')" -gt 0 ]; then + for asset in $(echo "$assets_json" | jq -r '.[] | @base64'); do + name=$(echo "$asset" | base64 --decode | jq -r '.name') + url=$(echo "$asset" | base64 --decode | jq -r '.browser_download_url') + + echo "Downloading $name from $url" + curl -L -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \ + -o "./release-assets/$name" "$url" + + if [ -n "$asset_files" ]; then + asset_files="$asset_files," + fi + asset_files="$asset_files./release-assets/$name" + done + fi + + echo "asset_files=$asset_files" >> $GITHUB_OUTPUT + echo "has_assets=$([ -n "$asset_files" ] && echo "true" || echo "false")" >> $GITHUB_OUTPUT + + - name: Check if release exists on GitCode + id: check-release + run: | + if [ "${{ steps.release.outputs.test_mode }}" = "true" ]; then + echo "🧪 Test mode enabled - skipping GitCode API check" + echo "exists=false" >> $GITHUB_OUTPUT + echo "Test mode: Simulating release does not exist on GitCode" + else + # First check if tag exists using GitCode tags API + echo "Checking if tag exists..." + tags_response=$(curl -s -w "%{http_code}" \ + -H "Accept: application/json" \ + "$GITCODE_API_BASE/repos/$GITCODE_OWNER/$GITCODE_REPO/tags?access_token=${{ secrets.GITCODE_ACCESS_TOKEN }}") + + tags_http_code="${tags_response: -3}" + tags_response_body="${tags_response%???}" + + echo "Tags API HTTP Code: $tags_http_code" + + tag_exists=false + if [ "$tags_http_code" = "200" ] || [ "$tags_http_code" = "201" ]; then + echo "Available tags (first 20):" + echo "$tags_response_body" | jq -r '.[] | .name' 2>/dev/null | head -20 || echo "Failed to parse tags" + + # Check if our target tag exists + if echo "$tags_response_body" | jq -e --arg tag "${{ steps.release.outputs.tag_name }}" '.[] | select(.name == $tag)' > /dev/null 2>&1; then + tag_exists=true + echo "✅ Tag ${{ steps.release.outputs.tag_name }} exists on GitCode" + else + echo "❌ Tag ${{ steps.release.outputs.tag_name }} does not exist on GitCode" + fi + else + echo "❌ Failed to fetch tags from GitCode (HTTP $tags_http_code): $tags_response_body" + fi + + # Then check if release exists (only if tag exists) + if [ "$tag_exists" = "true" ]; then + echo "Checking if release exists..." + response=$(curl -s -w "%{http_code}" \ + -H "Accept: application/json" \ + "$GITCODE_API_BASE/repos/$GITCODE_OWNER/$GITCODE_REPO/releases/tags/${{ steps.release.outputs.tag_name }}?access_token=${{ secrets.GITCODE_ACCESS_TOKEN }}") + else + echo "⚠️ Skipping release check since tag does not exist" + response="404Not Found" + fi + + http_code="${response: -3}" + response_body="${response%???}" + + echo "HTTP Code: $http_code" + echo "Response: $response_body" + + if [ "$http_code" = "200" ]; then + echo "exists=true" >> $GITHUB_OUTPUT + echo "Release already exists on GitCode" + else + echo "exists=false" >> $GITHUB_OUTPUT + echo "Release does not exist on GitCode" + fi + fi + + - name: Create release on GitCode + if: steps.check-release.outputs.exists == 'false' + id: create-release + run: | + payload=$(jq -n \ + --arg tag_name "${{ steps.release.outputs.tag_name }}" \ + --arg name "${{ steps.release.outputs.release_name }}" \ + --arg body "${{ steps.release.outputs.release_body }}" \ + --argjson prerelease "${{ steps.release.outputs.prerelease }}" \ + --argjson draft "${{ steps.release.outputs.draft }}" \ + '{ + tag_name: $tag_name, + name: $name, + body: $body, + prerelease: $prerelease, + draft: $draft + }') + + echo "Creating release with payload:" + echo "$payload" | jq . + + if [ "${{ steps.release.outputs.test_mode }}" = "true" ]; then + echo "🧪 Test mode enabled - skipping release creation on GitCode" + echo "✅ Test mode: Would create release successfully on GitCode" + echo "created=true" >> $GITHUB_OUTPUT + else + response=$(curl -s -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$payload" \ + "$GITCODE_API_BASE/repos/$GITCODE_OWNER/$GITCODE_REPO/releases?access_token=${{ secrets.GITCODE_ACCESS_TOKEN }}") + + http_code="${response: -3}" + response_body="${response%???}" + + echo "Create Release Response Code: $http_code" + echo "Create Release Response: $response_body" + + if [ "$http_code" = "201" ] || [ "$http_code" = "200" ]; then + echo "✅ Release created successfully on GitCode (HTTP $http_code)" + echo "created=true" >> $GITHUB_OUTPUT + else + echo "❌ Failed to create release on GitCode (HTTP $http_code)" + echo "Response: $response_body" + echo "created=false" >> $GITHUB_OUTPUT + exit 1 + fi + fi + + - name: Update existing release on GitCode + if: steps.check-release.outputs.exists == 'true' + id: update-release + run: | + payload=$(jq -n \ + --arg name "${{ steps.release.outputs.release_name }}" \ + --arg body "${{ steps.release.outputs.release_body }}" \ + --argjson prerelease "${{ steps.release.outputs.prerelease }}" \ + --argjson draft "${{ steps.release.outputs.draft }}" \ + '{ + name: $name, + body: $body, + prerelease: $prerelease, + draft: $draft + }') + + echo "Updating release with payload:" + echo "$payload" | jq . + + if [ "${{ steps.release.outputs.test_mode }}" = "true" ]; then + echo "🧪 Test mode enabled - skipping release update on GitCode" + echo "✅ Test mode: Would update release successfully on GitCode" + echo "updated=true" >> $GITHUB_OUTPUT + else + response=$(curl -s -w "%{http_code}" \ + -X PATCH \ + -H "Content-Type: application/json" \ + -H "Accept: application/json" \ + -d "$payload" \ + "$GITCODE_API_BASE/repos/$GITCODE_OWNER/$GITCODE_REPO/releases/${{ steps.release.outputs.tag_name }}?access_token=${{ secrets.GITCODE_ACCESS_TOKEN }}") + + http_code="${response: -3}" + response_body="${response%???}" + + echo "Update Release Response Code: $http_code" + echo "Update Release Response: $response_body" + + if [ "$http_code" = "200" ]; then + echo "✅ Release updated successfully on GitCode" + echo "updated=true" >> $GITHUB_OUTPUT + else + echo "❌ Failed to update release on GitCode" + echo "updated=false" >> $GITHUB_OUTPUT + exit 1 + fi + fi + + - name: Upload assets to GitCode release + if: steps.download-assets.outputs.has_assets == 'true' + run: | + if [ "${{ steps.release.outputs.test_mode }}" = "true" ]; then + echo "🧪 Test mode enabled - skipping asset upload to GitCode" + echo "Would upload the following assets:" + IFS=',' read -ra ASSET_FILES <<< "${{ steps.download-assets.outputs.asset_files }}" + for asset_file in "${ASSET_FILES[@]}"; do + if [ -f "$asset_file" ]; then + echo " - $(basename "$asset_file")" + fi + done + echo "✅ Test mode: Would upload all assets successfully to GitCode" + else + echo "📦 Uploading assets to GitCode release using JavaScript uploader..." + + # Make upload script executable + chmod +x ./scripts/upload-assets.js + + # Convert comma-separated asset files to array for JavaScript uploader + IFS=',' read -ra ASSET_FILES <<< "${{ steps.download-assets.outputs.asset_files }}" + + # Upload assets using the JavaScript uploader + node ./scripts/upload-assets.js \ + --token "${{ secrets.GITCODE_ACCESS_TOKEN }}" \ + --owner "$GITCODE_OWNER" \ + --repo "$GITCODE_REPO" \ + --tag "${{ steps.release.outputs.tag_name }}" \ + --concurrency 3 \ + --retry 3 \ + "${ASSET_FILES[@]}" + + upload_exit_code=$? + if [ $upload_exit_code -eq 0 ]; then + echo "✅ All assets uploaded successfully to GitCode" + else + echo "❌ Asset upload failed with exit code: $upload_exit_code" + exit 1 + fi + fi + + - name: Summary + run: | + if [ "${{ steps.release.outputs.test_mode }}" = "true" ]; then + echo "## 🧪 Test Mode - Release Sync Summary" >> $GITHUB_STEP_SUMMARY + else + echo "## 🚀 Release Sync Summary" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY + + echo "**Trigger:** ${{ github.event_name == 'workflow_dispatch' && '🔧 Manual' || '🚀 Automatic' }}" >> $GITHUB_STEP_SUMMARY + echo "**Release:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY + echo "**Name:** ${{ steps.release.outputs.release_name }}" >> $GITHUB_STEP_SUMMARY + echo "**GitCode Repository:** $GITCODE_OWNER/$GITCODE_REPO" >> $GITHUB_STEP_SUMMARY + + if [ "${{ steps.release.outputs.test_mode }}" = "true" ]; then + echo "**Mode:** 🧪 Test Mode (Dry Run)" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "${{ steps.check-release.outputs.exists }}" = "true" ]; then + if [ "${{ steps.release.outputs.test_mode }}" = "true" ]; then + echo "**Action:** Would update existing release ✅" >> $GITHUB_STEP_SUMMARY + else + echo "**Action:** Updated existing release ✅" >> $GITHUB_STEP_SUMMARY + fi + else + if [ "${{ steps.release.outputs.test_mode }}" = "true" ]; then + echo "**Action:** Would create new release ✅" >> $GITHUB_STEP_SUMMARY + else + echo "**Action:** Created new release ✅" >> $GITHUB_STEP_SUMMARY + fi + fi + + if [ "${{ steps.download-assets.outputs.has_assets }}" = "true" ]; then + if [ "${{ steps.release.outputs.test_mode }}" = "true" ]; then + echo "**Assets:** Would upload to GitCode ✅" >> $GITHUB_STEP_SUMMARY + else + echo "**Assets:** Uploaded to GitCode ✅" >> $GITHUB_STEP_SUMMARY + fi + else + echo "**Assets:** No assets to upload" >> $GITHUB_STEP_SUMMARY + fi + + echo "" >> $GITHUB_STEP_SUMMARY + + if [ "${{ steps.release.outputs.test_mode }}" = "true" ]; then + echo "Test completed successfully! 🧪 No actual changes were made to GitCode." >> $GITHUB_STEP_SUMMARY + else + echo "Release has been successfully synced to GitCode! 🎉" >> $GITHUB_STEP_SUMMARY + fi diff --git a/electron-builder.yml b/electron-builder.yml index 4fb1f67f..d44b266b 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -46,11 +46,6 @@ files: asarUnpack: - resources/** - '**/*.{metal,exp,lib}' -extraResources: - - from: resources/ffmpeg - to: ffmpeg - filter: - - '**/*' copyright: Copyright © 2025 EchoPlayer win: executableName: EchoPlayer @@ -60,9 +55,11 @@ win: - target: nsis arch: - x64 + - arm64 - target: portable arch: - x64 + - arm64 signtoolOptions: sign: scripts/win-sign.js verifyUpdateCodeSignature: false diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 7b77040e..7c0a0668 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -2,7 +2,6 @@ import fs from 'node:fs' import path from 'node:path' import react from '@vitejs/plugin-react-swc' -import { spawn } from 'child_process' import { CodeInspectorPlugin } from 'code-inspector-plugin' import { defineConfig, externalizeDepsPlugin } from 'electron-vite' import { resolve } from 'path' @@ -10,84 +9,10 @@ import { resolve } from 'path' const isDev = process.env.NODE_ENV === 'development' const isProd = process.env.NODE_ENV === 'production' -// FFmpeg 下载插件 -function ffmpegDownloadPlugin() { - return { - name: 'ffmpeg-download', - async buildStart() { - // 只在生产构建时下载 FFmpeg - if (!isProd) return - - // 根据构建目标决定下载哪个平台 - const targetPlatform = process.env.BUILD_TARGET_PLATFORM || process.platform - const targetArch = process.env.BUILD_TARGET_ARCH || process.arch - - // 检查是否已存在,避免重复下载 - const ffmpegPath = path.resolve( - 'resources/ffmpeg', - `${targetPlatform}-${targetArch}`, - targetPlatform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' - ) - - if (fs.existsSync(ffmpegPath)) { - console.log(`FFmpeg already exists for ${targetPlatform}-${targetArch}`) - return - } - - console.log(`Downloading FFmpeg for ${targetPlatform}-${targetArch}...`) - - try { - await new Promise((resolve, reject) => { - // 在不同环境中使用不同的命令来确保兼容性 - let command: string - let args: string[] - - if (process.platform === 'win32') { - // Windows 环境:使用 npm run 调用脚本,更可靠 - command = 'npm' - args = ['run', 'ffmpeg:download'] - } else { - // Unix 环境:直接使用 tsx - command = 'tsx' - args = ['scripts/download-ffmpeg.ts', 'platform', targetPlatform, targetArch] - } - - const downloadScript = spawn(command, args, { - stdio: 'inherit', - shell: process.platform === 'win32', - env: { - ...process.env, - BUILD_TARGET_PLATFORM: targetPlatform, - BUILD_TARGET_ARCH: targetArch - } - }) - - downloadScript.on('close', (code) => { - if (code === 0) { - console.log('FFmpeg Downloaded successfully') - resolve() - } else { - reject(new Error(`FFmpeg Download failed with exit code: ${code}`)) - } - }) - - downloadScript.on('error', (error) => { - reject(error) - }) - }) - } catch (error) { - console.error('FFmpeg Download failed:', error) - throw new Error(`Failed to download FFmpeg for ${targetPlatform}-${targetArch}: ${error}`) - } - } - } -} - export default defineConfig({ main: { plugins: [ externalizeDepsPlugin(), - ffmpegDownloadPlugin(), { name: 'copy-files', generateBundle() { @@ -125,41 +50,6 @@ export default defineConfig({ } } } - - // 复制 FFmpeg 文件到构建目录 - const ffmpegResourcesDir = path.resolve('resources/ffmpeg') - if (fs.existsSync(ffmpegResourcesDir)) { - const outResourcesDir = path.resolve('out/resources/ffmpeg') - - try { - // 确保输出目录存在 - fs.mkdirSync(outResourcesDir, { recursive: true }) - - // 复制整个 ffmpeg 目录 - const copyDirectoryRecursive = (src: string, dest: string) => { - if (!fs.existsSync(src)) return - - fs.mkdirSync(dest, { recursive: true }) - const items = fs.readdirSync(src) - - for (const item of items) { - const srcPath = path.join(src, item) - const destPath = path.join(dest, item) - - if (fs.statSync(srcPath).isDirectory()) { - copyDirectoryRecursive(srcPath, destPath) - } else { - fs.copyFileSync(srcPath, destPath) - } - } - } - - copyDirectoryRecursive(ffmpegResourcesDir, outResourcesDir) - console.log('FFmpeg files copied successfully') - } catch (error) { - console.warn('Failed to copy FFmpeg files:', error) - } - } } } ], diff --git a/package.json b/package.json index 10fd1524..5e1bb8fb 100644 --- a/package.json +++ b/package.json @@ -40,27 +40,12 @@ "test:e2e:ui": "playwright test --ui", "test:e2e:debug": "playwright test --debug", "test:e2e:report": "playwright show-report", - "version:current": "tsx scripts/version-manager.ts current", - "version:set": "tsx scripts/version-manager.ts set", - "version:major": "tsx scripts/version-manager.ts major", - "version:minor": "tsx scripts/version-manager.ts minor", - "version:patch": "tsx scripts/version-manager.ts patch", - "version:prerelease": "tsx scripts/version-manager.ts prerelease", - "version:beta": "tsx scripts/version-manager.ts minor beta", - "version:beta-patch": "tsx scripts/version-manager.ts patch beta", - "release": "npm run ffmpeg:download-all && npm run build:release && electron-builder --publish onTagOrDraft", - "release:all": "npm run ffmpeg:download-all && npm run build:release && electron-builder --publish always", - "release:never": "npm run ffmpeg:download-all && npm run build:release && electron-builder --publish never", - "release:draft": "npm run ffmpeg:download-all && npm run build:release && electron-builder --publish onTagOrDraft", "migrate": "tsx src/main/db/migration-cli.ts", "migrate:up": "npm run migrate up", "migrate:down": "npm run migrate down", "migrate:status": "npm run migrate status", "migrate:create": "npm run migrate create", "migrate:validate": "npm run migrate validate", - "release:rename": "tsx scripts/rename-artifacts.ts", - "release:auto": "tsx scripts/release.ts", - "release:check": "tsx scripts/pre-release-check.ts", "semantic-release": "semantic-release", "semantic-release:dry-run": "semantic-release --dry-run", "prepare": "husky", @@ -71,9 +56,7 @@ "ffmpeg:download": "tsx scripts/download-ffmpeg.ts current", "ffmpeg:download-all": "tsx scripts/download-ffmpeg.ts all", "ffmpeg:clean": "tsx scripts/download-ffmpeg.ts clean", - "ffmpeg:test": "tsx scripts/test-ffmpeg-integration.ts", - "prebuild": "npm run ffmpeg:download", - "prebuild:release": "echo 'FFmpeg already downloaded by release script'" + "ffmpeg:test": "tsx scripts/test-ffmpeg-integration.ts" }, "dependencies": { "@ant-design/icons": "^6.0.1", diff --git a/packages/shared/IpcChannel.ts b/packages/shared/IpcChannel.ts index b3d43538..ee6b15a4 100644 --- a/packages/shared/IpcChannel.ts +++ b/packages/shared/IpcChannel.ts @@ -77,6 +77,18 @@ export enum IpcChannel { Ffmpeg_GetVideoInfo = 'ffmpeg:get-video-info', Ffmpeg_Warmup = 'ffmpeg:warmup', Ffmpeg_GetWarmupStatus = 'ffmpeg:get-warmup-status', + Ffmpeg_GetInfo = 'ffmpeg:get-info', + Ffmpeg_AutoDetectAndDownload = 'ffmpeg:auto-detect-and-download', + + // FFmpeg 下载相关 IPC 通道 / FFmpeg download related IPC channels + FfmpegDownload_CheckExists = 'ffmpeg-download:check-exists', + FfmpegDownload_GetVersion = 'ffmpeg-download:get-version', + FfmpegDownload_Download = 'ffmpeg-download:download', + FfmpegDownload_GetProgress = 'ffmpeg-download:get-progress', + FfmpegDownload_Cancel = 'ffmpeg-download:cancel', + FfmpegDownload_Remove = 'ffmpeg-download:remove', + FfmpegDownload_GetAllVersions = 'ffmpeg-download:get-all-versions', + FfmpegDownload_CleanupTemp = 'ffmpeg-download:cleanup-temp', // MediaInfo 相关 IPC 通道 / MediaInfo related IPC channels MediaInfo_CheckExists = 'mediainfo:check-exists', diff --git a/scripts/pre-release-check.ts b/scripts/pre-release-check.ts deleted file mode 100644 index b6a53323..00000000 --- a/scripts/pre-release-check.ts +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env node - -/** - * 发布前检查脚本 / Pre-release Check Script - * - * 功能 / Features: - * 1. 检查版本号是否需要更新 / Check if version needs update - * 2. 检查 Git 状态 / Check Git status - * 3. 运行基本测试 / Run basic tests - * 4. 检查构建状态 / Check build status - */ - -import { execSync } from 'child_process' -import * as fs from 'fs' -import * as path from 'path' -import { fileURLToPath } from 'url' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const PACKAGE_JSON_PATH = path.join(__dirname, '..', 'package.json') - -interface PackageJson { - version: string - [key: string]: unknown -} - -function readPackageJson(): PackageJson { - const content = fs.readFileSync(PACKAGE_JSON_PATH, 'utf8') - return JSON.parse(content) as PackageJson -} - -function execCommand(command: string): string { - try { - return execSync(command, { encoding: 'utf8', stdio: 'pipe' }) - } catch { - return '' - } -} - -function checkGitStatus(): { isClean: boolean; hasUncommitted: boolean; branch: string } { - const status = execCommand('git status --porcelain') - const branch = execCommand('git branch --show-current').trim() - - return { - isClean: !status.trim(), - hasUncommitted: !!status.trim(), - branch - } -} - -// function getLastCommitMessage(): string { -// return execCommand('git log -1 --pretty=%B').trim() -// } - -// function getGitTagsSinceVersion(version: string): string[] { -// const tags = execCommand(`git tag --list --sort=-version:refname`) -// return tags.split('\n').filter((tag) => tag.trim().startsWith('v')) -// } - -function checkVersionNeedsUpdate(): { - needsUpdate: boolean - currentVersion: string - lastTag: string - commitsSinceTag: number -} { - const packageData = readPackageJson() - const currentVersion = packageData.version - - // 获取最新的版本标签 / Get latest version tag - const lastTag = execCommand('git describe --tags --abbrev=0').trim() - - // 计算自上次标签以来的提交数 / Count commits since last tag - const commitsSinceTag = parseInt( - execCommand('git rev-list --count HEAD ^' + lastTag).trim() || '0' - ) - - // 检查当前版本是否与最新标签匹配 / Check if current version matches latest tag - const needsUpdate = lastTag !== `v${currentVersion}` || commitsSinceTag > 0 - - return { - needsUpdate, - currentVersion, - lastTag: lastTag.replace('v', ''), - commitsSinceTag - } -} - -function analyzeChanges(): { hasFeatures: boolean; hasFixes: boolean; hasBreaking: boolean } { - // 分析自上次标签以来的提交类型 / Analyze commit types since last tag - const lastTag = execCommand('git describe --tags --abbrev=0').trim() - const commits = execCommand(`git log ${lastTag}..HEAD --oneline`).trim() - - if (!commits) { - return { hasFeatures: false, hasFixes: false, hasBreaking: false } - } - - const hasFeatures = /feat(\(.*\))?:/i.test(commits) - const hasFixes = /fix(\(.*\))?:/i.test(commits) - const hasBreaking = /BREAKING CHANGE|!:/i.test(commits) - - return { hasFeatures, hasFixes, hasBreaking } -} - -function suggestVersionType(): string { - const changes = analyzeChanges() - - if (changes.hasBreaking) { - return 'major' - } else if (changes.hasFeatures) { - return 'minor' - } else if (changes.hasFixes) { - return 'patch' - } else { - return 'patch' - } -} - -function main(): void { - console.log('🔍 EchoPlayer 发布前检查 / Pre-release Check') - console.log('=====================================') - - // 检查 Git 状态 / Check Git status - const gitStatus = checkGitStatus() - console.log(`\n📋 Git 状态 / Git Status:`) - console.log(`当前分支 / Current branch: ${gitStatus.branch}`) - console.log( - `工作区状态 / Working directory: ${gitStatus.isClean ? '✅ 干净' : '⚠️ 有未提交的更改'}` - ) - - if (gitStatus.hasUncommitted) { - console.log('\n⚠️ 检测到未提交的更改,建议先提交所有更改') - const status = execCommand('git status --porcelain') - console.log(status) - } - - // 检查版本状态 / Check version status - const versionInfo = checkVersionNeedsUpdate() - console.log(`\n📦 版本信息 / Version Information:`) - console.log(`当前版本 / Current version: ${versionInfo.currentVersion}`) - console.log(`最新标签 / Latest tag: ${versionInfo.lastTag}`) - console.log(`自标签以来的提交 / Commits since tag: ${versionInfo.commitsSinceTag}`) - - if (versionInfo.needsUpdate) { - console.log('\n🎯 版本更新建议 / Version Update Recommendation:') - const suggestedType = suggestVersionType() - console.log(`建议的版本类型 / Suggested version type: ${suggestedType}`) - - const changes = analyzeChanges() - if (changes.hasBreaking) { - console.log(' - 检测到破坏性更改 / Breaking changes detected') - } - if (changes.hasFeatures) { - console.log(' - 检测到新功能 / New features detected') - } - if (changes.hasFixes) { - console.log(' - 检测到修复 / Bug fixes detected') - } - - console.log('\n💡 更新版本命令建议 / Suggested version update commands:') - console.log(`npm run version:${suggestedType}`) - console.log('或使用自动化发布工具 / Or use automated release tool:') - console.log('npm run release:auto') - } else { - console.log('\n✅ 版本号已是最新') - } - - if (gitStatus.hasUncommitted || versionInfo.needsUpdate) { - console.log('\n⚠️ 建议在发布前完成以下操作:') - if (gitStatus.hasUncommitted) { - console.log(' 1. 提交所有未保存的更改') - } - if (versionInfo.needsUpdate) { - console.log(' 2. 更新版本号') - } - console.log(' 3. 运行完整测试套件') - console.log(' 4. 使用 npm run release:auto 进行自动化发布') - } else { - console.log('\n🎉 所有检查通过,可以进行发布!') - console.log('💡 使用以下命令进行发布:') - console.log(' npm run release:auto') - } -} - -main() diff --git a/scripts/release.ts b/scripts/release.ts deleted file mode 100644 index d6df72e9..00000000 --- a/scripts/release.ts +++ /dev/null @@ -1,226 +0,0 @@ -#!/usr/bin/env node - -/** - * 自动化发布脚本 / Automated Release Script - * - * 功能 / Features: - * 1. 检查当前版本状态 / Check current version status - * 2. 提示用户选择版本类型 / Prompt user to select version type - * 3. 自动更新版本号 / Automatically update version number - * 4. 构建项目 / Build project - * 5. 创建 Git 标签 / Create Git tag - * 6. 发布应用 / Publish application - */ - -import { execSync } from 'child_process' -import * as fs from 'fs' -import * as path from 'path' -import * as readline from 'readline' -import { fileURLToPath } from 'url' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const PACKAGE_JSON_PATH = path.join(__dirname, '..', 'package.json') - -interface PackageJson { - version: string - [key: string]: unknown -} - -function readPackageJson(): PackageJson { - const content = fs.readFileSync(PACKAGE_JSON_PATH, 'utf8') - return JSON.parse(content) as PackageJson -} - -function execCommand(command: string, description: string): void { - console.log(`\n🔄 ${description}...`) - try { - execSync(command, { stdio: 'inherit' }) - console.log(`✅ ${description} 完成`) - } catch { - console.error(`❌ ${description} 失败`) - process.exit(1) - } -} - -function promptUser(question: string): Promise { - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout - }) - - return new Promise((resolve) => { - rl.question(question, (answer: string) => { - rl.close() - resolve(answer.trim()) - }) - }) -} - -async function selectVersionType(): Promise { - console.log('\n📦 请选择版本类型 / Please select version type:') - console.log('1. patch - 补丁版本 (0.2.0 -> 0.2.1)') - console.log('2. minor - 次版本 (0.2.0 -> 0.3.0)') - console.log('3. major - 主版本 (0.2.0 -> 1.0.0)') - console.log('4. prerelease - 预发布递增 (0.2.0-alpha.2 -> 0.2.0-alpha.3)') - console.log('5. beta - Beta 版本') - console.log('6. beta-patch - Beta 补丁版本') - console.log('7. custom - 自定义版本号') - - const choice = await promptUser('请输入选择 (1-7): ') - - switch (choice) { - case '1': - return 'patch' - case '2': - return 'minor' - case '3': - return 'major' - case '4': - return 'prerelease' - case '5': - return 'beta' - case '6': - return 'beta-patch' - case '7': { - const customVersion = await promptUser('请输入自定义版本号 (例如: 1.0.0 或 1.0.0-beta.1): ') - return `custom:${customVersion}` - } - default: { - console.log('无效选择,使用默认的 patch 版本') - return 'patch' - } - } -} - -async function confirmRelease(currentVersion: string, newVersion: string): Promise { - console.log(`\n📋 发布信息 / Release Information:`) - console.log(`当前版本 / Current Version: ${currentVersion}`) - console.log(`新版本 / New Version: ${newVersion}`) - - const confirm = await promptUser('\n确认发布? (y/N): ') - return confirm.toLowerCase() === 'y' || confirm.toLowerCase() === 'yes' -} - -async function selectReleaseChannel(): Promise { - console.log('\n🚀 请选择发布渠道 / Please select release channel:') - console.log('1. draft - 草稿发布 (推荐)') - console.log('2. onTagOrDraft - 标签或草稿发布') - console.log('3. always - 总是发布') - console.log('4. never - 仅构建不发布') - - const choice = await promptUser('请输入选择 (1-4): ') - - switch (choice) { - case '1': - return 'release:draft' - case '2': - return 'release' - case '3': - return 'release:all' - case '4': - return 'release:never' - default: { - console.log('无效选择,使用默认的草稿发布') - return 'release:draft' - } - } -} - -async function main(): Promise { - console.log('🎯 EchoPlayer 自动化发布工具 / Automated Release Tool') - console.log('=====================================') - - // 检查当前版本 / Check current version - const packageData = readPackageJson() - const currentVersion = packageData.version - console.log(`\n📍 当前版本 / Current Version: ${currentVersion}`) - - // 检查 Git 状态 / Check Git status - try { - const gitStatus = execSync('git status --porcelain', { encoding: 'utf8' }) - if (gitStatus.trim()) { - console.log('\n⚠️ 检测到未提交的更改 / Uncommitted changes detected:') - console.log(gitStatus) - const proceed = await promptUser('是否继续发布? (y/N): ') - if (proceed.toLowerCase() !== 'y') { - console.log('发布已取消') - process.exit(0) - } - } - } catch (error) { - console.log('⚠️ 无法检查 Git 状态,继续执行...') - } - - // 选择版本类型 / Select version type - const versionChoice = await selectVersionType() - - // 更新版本号 / Update version number - let newVersion: string - if (versionChoice.startsWith('custom:')) { - const customVersion = versionChoice.replace('custom:', '') - execCommand(`npm run version:set -- ${customVersion}`, '设置自定义版本') - newVersion = customVersion - } else { - execCommand(`npm run version:${versionChoice}`, '更新版本号') - const updatedPackageData = readPackageJson() - newVersion = updatedPackageData.version - } - - // 确认发布 / Confirm release - const shouldRelease = await confirmRelease(currentVersion, newVersion) - if (!shouldRelease) { - console.log('发布已取消') - process.exit(0) - } - - // 运行测试 / Run tests - const runTests = await promptUser('\n是否运行测试? (Y/n): ') - if (runTests.toLowerCase() !== 'n' && runTests.toLowerCase() !== 'no') { - execCommand('npm run test:run', '运行单元测试') - execCommand('npm run lint', '代码检查') - execCommand('npm run typecheck', '类型检查') - } - - // 选择发布渠道 / Select release channel - const releaseChannel = await selectReleaseChannel() - - // 提交版本更改 / Commit version changes - try { - execCommand(`git add package.json`, '添加版本文件到 Git') - execCommand(`git commit -m "chore: release v${newVersion}"`, '提交版本更改') - execCommand(`git tag v${newVersion}`, '创建 Git 标签') - } catch (error) { - console.log('⚠️ Git 操作可能失败,继续构建...') - } - - // 构建和发布 / Build and release - execCommand(`npm run ${releaseChannel}`, '构建和发布应用') - - console.log('\n🎉 发布完成! / Release completed!') - console.log(`✅ 版本 ${newVersion} 已成功发布`) - - // 推送到远程仓库 / Push to remote repository - const pushToRemote = await promptUser('\n是否推送到远程仓库? (Y/n): ') - if (pushToRemote.toLowerCase() !== 'n' && pushToRemote.toLowerCase() !== 'no') { - try { - execCommand('git push origin main', '推送代码到远程仓库') - execCommand('git push origin --tags', '推送标签到远程仓库') - } catch (error) { - console.log('⚠️ 推送失败,请手动推送') - } - } - - console.log('\n🏁 所有操作完成!') -} - -// 处理未捕获的异常 / Handle uncaught exceptions -process.on('unhandledRejection', (error) => { - console.error('❌ 发布过程中出现错误:', error) - process.exit(1) -}) - -main().catch((error) => { - console.error('❌ 发布失败:', error) - process.exit(1) -}) diff --git a/scripts/rename-artifacts.ts b/scripts/rename-artifacts.ts deleted file mode 100644 index 8419eed9..00000000 --- a/scripts/rename-artifacts.ts +++ /dev/null @@ -1,499 +0,0 @@ -#!/usr/bin/env node - -/** - * 构建产物重命名脚本 / Build Artifacts Rename Script - * - * 功能 / Features: - * 1. 重命名构建产物以符合发布要求 / Rename build artifacts to meet release requirements - * 2. 处理不同平台的文件格式 / Handle different platform file formats - * 3. 确保文件名一致性 / Ensure filename consistency - * 4. 支持版本号和架构标识 / Support version and architecture identification - */ - -import * as fs from 'fs' -import * as path from 'path' - -// 项目根目录 / Project root directory -const PROJECT_ROOT = path.join(process.cwd()) -const DIST_DIR = path.join(PROJECT_ROOT, 'dist') -const PACKAGE_JSON_PATH = path.join(PROJECT_ROOT, 'package.json') - -interface PackageJson { - version: string - productName?: string - [key: string]: unknown -} - -/** - * 读取 package.json 获取版本信息 / Read package.json to get version info - */ -function getPackageInfo(): { version: string; productName: string } { - try { - const packageJson: PackageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON_PATH, 'utf8')) - return { - version: packageJson.version, - productName: packageJson.productName || 'echoplayer' - } - } catch (error) { - console.error('❌ 无法读取 package.json:', error) - process.exit(1) - } -} - -/** - * 获取平台和架构信息 / Get platform and architecture info - */ -function getPlatformInfo(): { platform: string; arch: string } { - // 优先使用 GitHub Actions 矩阵变量 / Prefer GitHub Actions matrix variables - const buildPlatform = process.env.BUILD_PLATFORM - const buildArch = process.env.BUILD_ARCH - - if (buildPlatform && buildArch) { - console.log(`🎯 使用 GitHub Actions 矩阵配置: ${buildPlatform}-${buildArch}`) - return { - platform: buildPlatform, - arch: buildArch - } - } - - // 回退到系统检测 / Fallback to system detection - const platform = process.env.RUNNER_OS?.toLowerCase() || process.platform - const arch = process.env.RUNNER_ARCH || process.arch - - // 标准化平台名称 / Normalize platform names - const normalizedPlatform = - platform === 'windows' || platform === 'win32' - ? 'win' - : platform === 'macos' || platform === 'darwin' - ? 'mac' - : platform === 'linux' - ? 'linux' - : platform - - // 标准化架构名称 / Normalize architecture names - // 对于 Linux 平台,保留 amd64 架构名称 / For Linux platform, keep amd64 architecture name - const normalizedArch = (() => { - if (normalizedPlatform === 'linux') { - // Linux 平台保留原有架构名称,特别是 amd64 / Keep original arch names for Linux, especially amd64 - return arch === 'x86_64' ? 'amd64' : arch === 'x64' ? 'amd64' : arch - } else { - // 其他平台使用标准化命名 / Use normalized naming for other platforms - return arch === 'x64' ? 'x64' : arch === 'arm64' ? 'arm64' : arch === 'x86_64' ? 'x64' : arch - } - })() - - console.log(`🔍 使用系统检测: ${normalizedPlatform}-${normalizedArch}`) - return { - platform: normalizedPlatform, - arch: normalizedArch - } -} - -/** - * 检查文件是否存在 / Check if file exists - */ -function fileExists(filePath: string): boolean { - try { - return fs.existsSync(filePath) - } catch { - return false - } -} - -/** - * 重命名文件 / Rename file - */ -function renameFile(oldPath: string, newPath: string): boolean { - try { - if (!fileExists(oldPath)) { - console.log(`⚠️ 源文件不存在: ${oldPath}`) - return false - } - - if (fileExists(newPath)) { - console.log(`⚠️ 目标文件已存在: ${newPath}`) - return false - } - - fs.renameSync(oldPath, newPath) - console.log(`✅ 重命名成功: ${path.basename(oldPath)} -> ${path.basename(newPath)}`) - return true - } catch (error) { - console.error(`❌ 重命名失败: ${oldPath} -> ${newPath}`, error) - return false - } -} - -/** - * 列出 dist 目录中的所有文件 / List all files in dist directory - */ -function listDistFiles(): string[] { - try { - const files = fs.readdirSync(DIST_DIR, { recursive: true }) - return files - .filter( - (file) => typeof file === 'string' && !fs.statSync(path.join(DIST_DIR, file)).isDirectory() - ) - .map((file) => file.toString()) - } catch (error) { - console.error('❌ 无法读取 dist 目录:', error) - return [] - } -} - -/** - * 处理 Windows 构建产物 / Handle Windows build artifacts - */ -function handleWindowsArtifacts(version: string, productName: string, arch: string): number { - let renamedCount = 0 - const files = listDistFiles() - - // 查找 Windows 安装程序 / Find Windows installer - const setupPattern = /\.exe$/i - const setupFiles = files.filter((file) => setupPattern.test(file)) - - for (const file of setupFiles) { - const oldPath = path.join(DIST_DIR, file) - const expectedName = `${productName}-${version}-${arch}-setup.exe` - const newPath = path.join(DIST_DIR, expectedName) - - if (path.basename(file) !== expectedName) { - if (renameFile(oldPath, newPath)) { - renamedCount++ - } - } else { - console.log(`✅ Windows 安装程序已是正确名称: ${file}`) - renamedCount++ - } - } - - // 更新 latest.yml 文件中的文件引用 / Update file references in latest.yml - const latestYmlPath = path.join(DIST_DIR, 'latest.yml') - if (fs.existsSync(latestYmlPath)) { - try { - let yamlContent = fs.readFileSync(latestYmlPath, 'utf8') - let updated = false - - // 更新 EXE 文件引用 / Update EXE file references - const oldExeName = `${productName}-${version}-setup.exe` - const newExeName = `${productName}-${version}-${arch}-setup.exe` - if (yamlContent.includes(oldExeName)) { - yamlContent = yamlContent.replace(new RegExp(oldExeName, 'g'), newExeName) - updated = true - console.log(`✅ 更新 YAML 中的 EXE 文件引用: ${oldExeName} -> ${newExeName}`) - } - - if (updated) { - fs.writeFileSync(latestYmlPath, yamlContent, 'utf8') - console.log(`✅ 已更新 latest.yml 文件`) - renamedCount++ - } - } catch (error) { - console.error(`❌ 更新 latest.yml 文件失败:`, error) - } - } - - return renamedCount -} - -/** - * 处理 macOS 构建产物 / Handle macOS build artifacts - */ -function handleMacOSArtifacts(version: string, productName: string, arch: string): number { - let renamedCount = 0 - const files = listDistFiles() - - // 查找 macOS DMG 文件 / Find macOS DMG files - const dmgPattern = /\.dmg$/i - const dmgFiles = files.filter((file) => dmgPattern.test(file)) - - for (const file of dmgFiles) { - const oldPath = path.join(DIST_DIR, file) - const expectedName = `${productName}-${version}-${arch}.dmg` - const newPath = path.join(DIST_DIR, expectedName) - - if (path.basename(file) !== expectedName) { - if (renameFile(oldPath, newPath)) { - renamedCount++ - } - } else { - console.log(`✅ macOS DMG 文件已是正确名称: ${file}`) - renamedCount++ - } - } - - // 查找 macOS ZIP 文件 / Find macOS ZIP files - const zipPattern = /\.zip$/i - const zipFiles = files.filter((file) => zipPattern.test(file)) - - for (const file of zipFiles) { - const oldPath = path.join(DIST_DIR, file) - const expectedName = `${productName}-${version}-${arch}.zip` - const newPath = path.join(DIST_DIR, expectedName) - - if (path.basename(file) !== expectedName) { - if (renameFile(oldPath, newPath)) { - renamedCount++ - } - } else { - console.log(`✅ macOS ZIP 文件已是正确名称: ${file}`) - renamedCount++ - } - } - - // 查找 macOS blockmap 文件 / Find macOS blockmap files - const blockmapPattern = /\.blockmap$/i - const blockmapFiles = files.filter((file) => blockmapPattern.test(file)) - - for (const file of blockmapFiles) { - const oldPath = path.join(DIST_DIR, file) - let expectedName = '' - - if (file.includes('.dmg.blockmap')) { - expectedName = `${productName}-${version}-${arch}.dmg.blockmap` - } else if (file.includes('.zip.blockmap')) { - expectedName = `${productName}-${version}-${arch}.zip.blockmap` - } else { - continue // 跳过不匹配的 blockmap 文件 - } - - const newPath = path.join(DIST_DIR, expectedName) - - if (path.basename(file) !== expectedName) { - if (renameFile(oldPath, newPath)) { - renamedCount++ - } - } else { - console.log(`✅ macOS blockmap 文件已是正确名称: ${file}`) - renamedCount++ - } - } - - // 更新 latest-mac.yml 文件中的文件引用 / Update file references in latest-mac.yml - const latestMacYmlPath = path.join(DIST_DIR, 'latest-mac.yml') - if (fs.existsSync(latestMacYmlPath)) { - try { - let yamlContent = fs.readFileSync(latestMacYmlPath, 'utf8') - let updated = false - - // 更新 ZIP 文件引用 / Update ZIP file references - const oldZipName = `${productName}-${version}-mac.zip` - const newZipName = `${productName}-${version}-${arch}.zip` - if (yamlContent.includes(oldZipName)) { - yamlContent = yamlContent.replace(new RegExp(oldZipName, 'g'), newZipName) - updated = true - console.log(`✅ 更新 YAML 中的 ZIP 文件引用: ${oldZipName} -> ${newZipName}`) - } - - // 更新 DMG 文件引用 / Update DMG file references - const oldDmgName = `${productName}-${version}.dmg` - const newDmgName = `${productName}-${version}-${arch}.dmg` - if (yamlContent.includes(oldDmgName)) { - yamlContent = yamlContent.replace(new RegExp(oldDmgName, 'g'), newDmgName) - updated = true - console.log(`✅ 更新 YAML 中的 DMG 文件引用: ${oldDmgName} -> ${newDmgName}`) - } - - if (updated) { - fs.writeFileSync(latestMacYmlPath, yamlContent, 'utf8') - console.log(`✅ 已更新 latest-mac.yml 文件`) - renamedCount++ - } - } catch (error) { - console.error(`❌ 更新 latest-mac.yml 文件失败:`, error) - } - } - - return renamedCount -} - -/** - * 处理 Linux 构建产物 / Handle Linux build artifacts - */ - -function handleLinuxArtifacts(version: string, productName: string, arch: string): number { - let renamedCount = 0 - const files = listDistFiles() - - // 查找 Linux AppImage 文件 / Find Linux AppImage files - const appImagePattern = /\.AppImage$/i - const appImageFiles = files.filter((file) => appImagePattern.test(file)) - - for (const file of appImageFiles) { - const oldPath = path.join(DIST_DIR, file) - - // 检测实际文件名中的架构标识 / Detect architecture identifier in actual filename - let targetArch = arch - if (file.includes('x86_64') && arch === 'x64') { - // 如果文件名包含 x86_64 而矩阵配置是 x64,转换为 amd64 - targetArch = 'amd64' - console.log(`🔄 检测到 x86_64 架构,转换为 amd64`) - } - - const expectedName = `${productName}-${version}-${targetArch}.AppImage` - const newPath = path.join(DIST_DIR, expectedName) - - if (path.basename(file) !== expectedName) { - if (renameFile(oldPath, newPath)) { - renamedCount++ - } - } else { - console.log(`✅ Linux AppImage 文件已是正确名称: ${file}`) - renamedCount++ - } - } - - // 查找 Linux DEB 文件 / Find Linux DEB files - const debPattern = /\.deb$/i - const debFiles = files.filter((file) => debPattern.test(file)) - - for (const file of debFiles) { - const oldPath = path.join(DIST_DIR, file) - - // 检测实际文件名中的架构标识 / Detect architecture identifier in actual filename - let targetArch = arch - if (file.includes('amd64') && arch === 'x64') { - // 如果文件名包含 amd64 而矩阵配置是 x64,保持 amd64 - targetArch = 'amd64' - console.log(`🔄 检测到 amd64 架构,保持 amd64`) - } - - const expectedName = `${productName}-${version}-${targetArch}.deb` - const newPath = path.join(DIST_DIR, expectedName) - - if (path.basename(file) !== expectedName) { - if (renameFile(oldPath, newPath)) { - renamedCount++ - } - } else { - console.log(`✅ Linux DEB 文件已是正确名称: ${file}`) - renamedCount++ - } - } - - // 更新 latest-linux.yml 文件中的文件引用 / Update file references in latest-linux.yml - const latestLinuxYmlPath = path.join(DIST_DIR, 'latest-linux.yml') - if (fs.existsSync(latestLinuxYmlPath)) { - try { - let yamlContent = fs.readFileSync(latestLinuxYmlPath, 'utf8') - let updated = false - - // 确定目标架构名称 / Determine target architecture name - let targetArch = arch - if (yamlContent.includes('x86_64') && arch === 'x64') { - targetArch = 'amd64' - console.log(`🔄 YAML 文件中检测到 x86_64,转换为 amd64`) - } - - // 更新 AppImage 文件引用 / Update AppImage file references - const oldAppImageName = `${productName}-${version}.AppImage` - const newAppImageName = `${productName}-${version}-${targetArch}.AppImage` - if (yamlContent.includes(oldAppImageName)) { - yamlContent = yamlContent.replace(new RegExp(oldAppImageName, 'g'), newAppImageName) - updated = true - console.log(`✅ 更新 YAML 中的 AppImage 文件引用: ${oldAppImageName} -> ${newAppImageName}`) - } - - // 处理可能存在的 x86_64 AppImage 引用 / Handle possible x86_64 AppImage references - const oldAppImageNameX86 = `${productName}-${version}-x86_64.AppImage` - if (yamlContent.includes(oldAppImageNameX86) && targetArch === 'amd64') { - yamlContent = yamlContent.replace(new RegExp(oldAppImageNameX86, 'g'), newAppImageName) - updated = true - console.log( - `✅ 更新 YAML 中的 x86_64 AppImage 文件引用: ${oldAppImageNameX86} -> ${newAppImageName}` - ) - } - - // 更新 DEB 文件引用 / Update DEB file references - const oldDebName = `${productName}-${version}.deb` - const newDebName = `${productName}-${version}-${targetArch}.deb` - if (yamlContent.includes(oldDebName)) { - yamlContent = yamlContent.replace(new RegExp(oldDebName, 'g'), newDebName) - updated = true - console.log(`✅ 更新 YAML 中的 DEB 文件引用: ${oldDebName} -> ${newDebName}`) - } - - if (updated) { - fs.writeFileSync(latestLinuxYmlPath, yamlContent, 'utf8') - console.log(`✅ 已更新 latest-linux.yml 文件`) - renamedCount++ - } - } catch (error) { - console.error(`❌ 更新 latest-linux.yml 文件失败:`, error) - } - } - - return renamedCount -} - -/** - * 主函数 / Main function - */ -async function main(): Promise { - console.log('🔄 开始重命名构建产物...') - console.log('🔄 Starting to rename build artifacts...') - - // 检查 dist 目录是否存在 / Check if dist directory exists - if (!fileExists(DIST_DIR)) { - console.error('❌ dist 目录不存在,请先运行构建命令') - process.exit(1) - } - - // 获取项目信息 / Get project info - const { version, productName } = getPackageInfo() - const { platform, arch } = getPlatformInfo() - - console.log(`📦 产品名称: ${productName}`) - console.log(`🏷️ 版本号: ${version}`) - console.log(`💻 平台: ${platform}`) - console.log(`🏗️ 架构: ${arch}`) - - // 列出当前 dist 目录中的文件 / List current files in dist directory - const distFiles = listDistFiles() - console.log(`📁 dist 目录中的文件 (${distFiles.length} 个):`) - distFiles.forEach((file) => console.log(` - ${file}`)) - - let totalRenamed = 0 - - // 根据平台处理构建产物 / Handle build artifacts based on platform - switch (platform) { - case 'win': - case 'windows': - totalRenamed += handleWindowsArtifacts(version, productName, arch) - break - - case 'mac': - case 'macos': - case 'darwin': - totalRenamed += handleMacOSArtifacts(version, productName, arch) - break - - case 'linux': - totalRenamed += handleLinuxArtifacts(version, productName, arch) - break - - default: - console.log(`⚠️ 未知平台: ${platform},跳过重命名`) - break - } - - // 输出结果 / Output results - console.log(`\n📊 重命名完成统计:`) - console.log(`📊 Rename completion statistics:`) - console.log(`✅ 成功重命名文件数: ${totalRenamed}`) - console.log(`✅ Successfully renamed files: ${totalRenamed}`) - - if (totalRenamed === 0) { - console.log('⚠️ 没有文件需要重命名或重命名失败') - console.log('⚠️ No files need to be renamed or rename failed') - } - - console.log('🎉 构建产物重命名完成!') - console.log('🎉 Build artifacts rename completed!') -} - -// 运行主函数 / Run main function -main().catch((error) => { - console.error('❌ 重命名过程中出现错误:', error) - process.exit(1) -}) diff --git a/scripts/upload-assets.js b/scripts/upload-assets.js new file mode 100644 index 00000000..de54fc51 --- /dev/null +++ b/scripts/upload-assets.js @@ -0,0 +1,426 @@ +#!/usr/bin/env node + +const https = require('https') +const fs = require('fs') +const path = require('path') +const { URL } = require('url') + +/** + * GitCode 资产上传脚本 + * 功能: + * 1. 并发上传文件到 GitCode + * 2. 检查文件是否已存在,避免重复上传 + * 3. 支持断点续传和错误重试 + */ + +class GitCodeUploader { + constructor(options) { + this.accessToken = options.accessToken + this.owner = options.owner + this.repo = options.repo + this.tag = options.tag + this.concurrency = options.concurrency || 3 + this.retryAttempts = options.retryAttempts || 3 + this.baseUrl = 'https://api.gitcode.com/api/v5' + } + + /** + * HTTP 请求工具方法 + */ + async httpRequest(url, options = {}) { + return new Promise((resolve, reject) => { + const urlObj = new URL(url) + const requestOptions = { + hostname: urlObj.hostname, + port: urlObj.port || 443, + path: urlObj.pathname + urlObj.search, + method: options.method || 'GET', + headers: options.headers || {}, + ...options.httpsOptions + } + + const req = https.request(requestOptions, (res) => { + let data = '' + + res.on('data', (chunk) => { + data += chunk + }) + + res.on('end', () => { + const result = { + statusCode: res.statusCode, + headers: res.headers, + data: data + } + + try { + if (data && res.headers['content-type']?.includes('application/json')) { + result.json = JSON.parse(data) + } + } catch (e) { + // JSON 解析失败,保持原始数据 + } + + resolve(result) + }) + }) + + req.on('error', reject) + + if (options.body) { + if (options.body instanceof Buffer || typeof options.body === 'string') { + req.write(options.body) + } else { + req.write(JSON.stringify(options.body)) + } + } + + req.end() + }) + } + + /** + * 获取现有的 release 信息和资产列表 + */ + async getExistingAssets() { + const url = `${this.baseUrl}/repos/${this.owner}/${this.repo}/releases?access_token=${this.accessToken}` + + try { + const response = await this.httpRequest(url) + + if (response.statusCode === 200 && response.json && Array.isArray(response.json)) { + // 从 releases 数组中找到匹配的 tag + const targetRelease = response.json.find((release) => release.tag_name === this.tag) + + if (targetRelease) { + const assets = targetRelease.assets || [] + const assetNames = new Set(assets.map((asset) => asset.name)) + console.log(`✓ 找到现有 release ${this.tag},包含 ${assets.length} 个资产`) + + // GitCode releases API 使用 tag_name 作为标识符 + const releaseId = targetRelease.tag_name + console.log(` 使用标识符: ${releaseId}`) + + if (assets.length > 0) { + console.log(` 现有资产:`) + assets.slice(0, 3).forEach((asset) => { + console.log(` - ${asset.name} (${asset.type})`) + }) + if (assets.length > 3) { + console.log(` ... 以及其他 ${assets.length - 3} 个文件`) + } + } + + return { releaseId: releaseId, existingAssets: assetNames } + } else { + console.log(`✗ Release ${this.tag} 不存在`) + return { releaseId: null, existingAssets: new Set() } + } + } else { + throw new Error(`获取 releases 列表失败: ${response.statusCode} ${response.data}`) + } + } catch (error) { + console.error('获取现有资产失败:', error.message) + throw error + } + } + + /** + * 获取上传 URL + */ + async getUploadUrl(releaseId, fileName) { + const url = `${this.baseUrl}/repos/${this.owner}/${this.repo}/releases/${releaseId}/upload_url?access_token=${this.accessToken}&file_name=${encodeURIComponent(fileName)}` + + try { + const response = await this.httpRequest(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json' + } + }) + + if (response.statusCode === 200 && response.json) { + return response.json + } else { + throw new Error(`获取上传 URL 失败: ${response.statusCode} ${response.data}`) + } + } catch (error) { + console.error(`获取 ${fileName} 上传 URL 失败:`, error.message) + throw error + } + } + + /** + * 上传文件到 GitCode 对象存储 + */ + async uploadFileToStorage(uploadInfo, filePath) { + const fileName = path.basename(filePath) + const fileBuffer = fs.readFileSync(filePath) + const fileSize = fileBuffer.length + + const uploadUrl = uploadInfo.url + + console.log(uploadInfo.url) + console.log(uploadInfo.headers) + + try { + const response = await this.httpRequest(uploadUrl, { + method: 'PUT', + headers: { ...uploadInfo.headers, 'Content-Length': fileSize }, + body: fileBuffer + }) + + if (response.statusCode === 200) { + console.log(`✓ ${fileName} 上传成功 (${fileSize} bytes)`) + return true + } else { + throw new Error(`上传失败: ${response.statusCode} ${response.data}`) + } + } catch (error) { + console.error(`上传 ${fileName} 到存储失败:`, error.message) + throw error + } + } + + /** + * 上传单个文件(带重试) + */ + async uploadSingleFile(releaseId, filePath, existingAssets) { + const fileName = path.basename(filePath) + + // 检查文件是否已存在 + if (existingAssets.has(fileName)) { + console.log(`⚠ ${fileName} 已存在,跳过上传`) + return { success: true, skipped: true } + } + + if (!fs.existsSync(filePath)) { + console.log(`⚠ ${fileName} 文件不存在,跳过`) + return { success: false, error: 'File not found' } + } + + const fileStats = fs.statSync(filePath) + const fileSize = fileStats.size + + for (let attempt = 1; attempt <= this.retryAttempts; attempt++) { + try { + console.log( + `⏳ 上传 ${fileName} (${fileSize} bytes) - 尝试 ${attempt}/${this.retryAttempts}` + ) + + // 获取上传 URL + const uploadInfo = await this.getUploadUrl(releaseId, fileName) + + // 上传到对象存储 + await this.uploadFileToStorage(uploadInfo, filePath) + + return { success: true, skipped: false } + } catch (error) { + console.error( + `上传 ${fileName} 失败 (尝试 ${attempt}/${this.retryAttempts}):`, + error.message + ) + + if (attempt === this.retryAttempts) { + return { success: false, error: error.message } + } + + // 等待后重试 + await new Promise((resolve) => setTimeout(resolve, 1000 * attempt)) + } + } + } + + /** + * 并发上传多个文件 + */ + async uploadFiles(filePaths) { + console.log(`开始上传 ${filePaths.length} 个文件 (并发数: ${this.concurrency})`) + + // 获取现有资产列表 + const { releaseId, existingAssets } = await this.getExistingAssets() + + if (!releaseId) { + throw new Error(`Release ${this.tag} 不存在,无法上传资产`) + } + + // 过滤出需要上传的文件 + const filesToUpload = filePaths.filter((filePath) => { + const fileName = path.basename(filePath) + return !existingAssets.has(fileName) && fs.existsSync(filePath) + }) + + console.log(`需要上传 ${filesToUpload.length} 个新文件`) + + if (filesToUpload.length === 0) { + console.log('所有文件都已存在,无需上传') + return { + total: filePaths.length, + success: filePaths.length, + failed: 0, + skipped: filePaths.length + } + } + + // 并发上传 + const results = [] + const semaphore = new Array(this.concurrency).fill(null) + + const uploadPromises = filesToUpload.map(async (filePath) => { + // 等待信号量 + await new Promise((resolve) => { + const checkSemaphore = () => { + const index = semaphore.indexOf(null) + if (index !== -1) { + semaphore[index] = filePath + resolve() + } else { + setTimeout(checkSemaphore, 100) + } + } + checkSemaphore() + }) + + try { + const result = await this.uploadSingleFile(releaseId, filePath, existingAssets) + result.filePath = filePath + results.push(result) + } finally { + // 释放信号量 + const index = semaphore.indexOf(filePath) + if (index !== -1) { + semaphore[index] = null + } + } + }) + + await Promise.all(uploadPromises) + + // 统计结果 + const stats = { + total: filePaths.length, + success: results.filter((r) => r.success).length, + failed: results.filter((r) => !r.success).length, + skipped: + results.filter((r) => r.skipped || existingAssets.has(path.basename(r.filePath))).length + + (filePaths.length - filesToUpload.length) + } + + console.log(`\n上传完成:`) + console.log(` 总计: ${stats.total}`) + console.log(` 成功: ${stats.success}`) + console.log(` 失败: ${stats.failed}`) + console.log(` 跳过: ${stats.skipped}`) + + // 输出失败的文件 + const failedFiles = results.filter((r) => !r.success) + if (failedFiles.length > 0) { + console.log('\n失败的文件:') + failedFiles.forEach((result) => { + console.log(` - ${path.basename(result.filePath)}: ${result.error}`) + }) + } + + return stats + } +} + +// 命令行接口 +async function main() { + const args = process.argv.slice(2) + + if (args.length === 0 || args.includes('--help') || args.includes('-h')) { + console.log(` +GitCode 资产上传工具 + +用法: node upload-assets.js [选项] <文件路径...> + +选项: + --token GitCode access token (必需) + --owner 仓库所有者 (必需) + --repo 仓库名称 (必需) + --tag 发布标签 (必需) + --concurrency 并发数量 (默认: 3) + --retry 重试次数 (默认: 3) + --help, -h 显示帮助信息 + +示例: + node upload-assets.js --token xxx --owner mkdir700 --repo EchoPlayer --tag v1.0.0 file1.zip file2.deb + +环境变量: + GITCODE_ACCESS_TOKEN GitCode access token + GITCODE_OWNER 仓库所有者 + GITCODE_REPO 仓库名称 + GITCODE_TAG 发布标签 +`) + process.exit(0) + } + + // 解析命令行参数 + const options = { + accessToken: process.env.GITCODE_ACCESS_TOKEN, + owner: process.env.GITCODE_OWNER, + repo: process.env.GITCODE_REPO, + tag: process.env.GITCODE_TAG, + concurrency: 3, + retryAttempts: 3 + } + + const filePaths = [] + + for (let i = 0; i < args.length; i++) { + const arg = args[i] + + if (arg === '--token' && i + 1 < args.length) { + options.accessToken = args[++i] + } else if (arg === '--owner' && i + 1 < args.length) { + options.owner = args[++i] + } else if (arg === '--repo' && i + 1 < args.length) { + options.repo = args[++i] + } else if (arg === '--tag' && i + 1 < args.length) { + options.tag = args[++i] + } else if (arg === '--concurrency' && i + 1 < args.length) { + options.concurrency = parseInt(args[++i]) + } else if (arg === '--retry' && i + 1 < args.length) { + options.retryAttempts = parseInt(args[++i]) + } else if (!arg.startsWith('--')) { + filePaths.push(arg) + } + } + + // 验证必需参数 + const required = ['accessToken', 'owner', 'repo', 'tag'] + const missing = required.filter((key) => !options[key]) + + if (missing.length > 0) { + console.error(`错误: 缺少必需参数: ${missing.join(', ')}`) + process.exit(1) + } + + if (filePaths.length === 0) { + console.error('错误: 未指定要上传的文件') + process.exit(1) + } + + try { + const uploader = new GitCodeUploader(options) + const stats = await uploader.uploadFiles(filePaths) + + if (stats.failed > 0) { + process.exit(1) + } + } catch (error) { + console.error('上传失败:', error.message) + process.exit(1) + } +} + +// 如果直接运行此脚本 +if (require.main === module) { + main().catch((error) => { + console.error('未处理的错误:', error) + process.exit(1) + }) +} + +module.exports = GitCodeUploader diff --git a/scripts/version-manager.ts b/scripts/version-manager.ts deleted file mode 100644 index 841be712..00000000 --- a/scripts/version-manager.ts +++ /dev/null @@ -1,231 +0,0 @@ -#!/usr/bin/env node - -import * as fs from 'fs' -import * as path from 'path' -import { fileURLToPath } from 'url' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const PACKAGE_JSON_PATH = path.join(__dirname, '..', 'package.json') - -/** - * Version types and their meanings: - * - dev: Development version (for active development) - * - test: Test version (for internal testing) - * - alpha: Alpha version (early preview, may have bugs) - * - beta: Beta version (feature complete, testing phase) - * - stable: Stable version (production ready) - */ - -type VersionType = 'dev' | 'test' | 'alpha' | 'beta' | 'stable' -type IncrementType = 'major' | 'minor' | 'patch' - -interface PackageJson { - version: string - [key: string]: unknown -} - -interface ParsedVersion { - major: number - minor: number - patch: number - prerelease: string | null -} - -function readPackageJson(): PackageJson { - const content = fs.readFileSync(PACKAGE_JSON_PATH, 'utf8') - return JSON.parse(content) as PackageJson -} - -function writePackageJson(packageData: PackageJson): void { - fs.writeFileSync(PACKAGE_JSON_PATH, JSON.stringify(packageData, null, 2) + '\n') -} - -function parseVersion(version: string): ParsedVersion { - const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/) - if (!match) { - throw new Error(`Invalid version format: ${version}`) - } - - const [, major, minor, patch, prerelease] = match - return { - major: parseInt(major, 10), - minor: parseInt(minor, 10), - patch: parseInt(patch, 10), - prerelease: prerelease || null - } -} - -function formatVersion(versionObj: ParsedVersion): string { - const base = `${versionObj.major}.${versionObj.minor}.${versionObj.patch}` - return versionObj.prerelease ? `${base}-${versionObj.prerelease}` : base -} - -function detectVersionType(version: string): VersionType { - if (!version) return 'stable' - - if (version.includes('dev')) return 'dev' - if (version.includes('test')) return 'test' - if (version.includes('alpha')) return 'alpha' - if (version.includes('beta')) return 'beta' - return 'stable' -} - -function incrementVersion( - currentVersion: string, - type: IncrementType, - versionType: VersionType = 'stable' -): string { - const parsed = parseVersion(currentVersion) - - switch (type) { - case 'major': { - parsed.major++ - parsed.minor = 0 - parsed.patch = 0 - break - } - case 'minor': { - parsed.minor++ - parsed.patch = 0 - break - } - case 'patch': { - parsed.patch++ - break - } - default: { - throw new Error(`Invalid increment type: ${type}`) - } - } - - // Set prerelease based on version type - if (versionType === 'stable') { - parsed.prerelease = null - } else if (versionType === 'beta') { - parsed.prerelease = 'beta.1' - } else if (versionType === 'alpha') { - parsed.prerelease = 'alpha.1' - } else if (versionType === 'dev') { - parsed.prerelease = 'dev.1' - } else if (versionType === 'test') { - parsed.prerelease = 'test.1' - } - - return formatVersion(parsed) -} - -function incrementPrerelease(currentVersion: string): string { - const parsed = parseVersion(currentVersion) - - if (!parsed.prerelease) { - throw new Error('Cannot increment prerelease on stable version') - } - - const match = parsed.prerelease.match(/^(.+)\.(\d+)$/) - if (!match) { - throw new Error(`Invalid prerelease format: ${parsed.prerelease}`) - } - - const [, type, number] = match - parsed.prerelease = `${type}.${parseInt(number, 10) + 1}` - - return formatVersion(parsed) -} - -function main(): void { - const args = process.argv.slice(2) - const command = args[0] - - if (!command) { - console.log(` -Usage: node version-manager.js [options] - -Commands: - current Show current version and type - set Set specific version (e.g., 1.0.0, 1.0.0-beta.1) - major [type] Increment major version (type: stable|beta|alpha|dev|test) - minor [type] Increment minor version (type: stable|beta|alpha|dev|test) - patch [type] Increment patch version (type: stable|beta|alpha|dev|test) - prerelease Increment prerelease number (e.g., beta.1 -> beta.2) - -Examples: - node version-manager.js current - node version-manager.js set 1.0.0-beta.1 - node version-manager.js minor beta - node version-manager.js prerelease - `) - return - } - - const packageData = readPackageJson() - const currentVersion = packageData.version - const currentType = detectVersionType(currentVersion) - - try { - switch (command) { - case 'current': { - console.log(`Current version: ${currentVersion}`) - console.log(`Version type: ${currentType}`) - break - } - - case 'set': { - const newVersion = args[1] - if (!newVersion) { - console.error('Please provide a version number') - process.exit(1) - } - packageData.version = newVersion - writePackageJson(packageData) - console.log(`Version updated to: ${newVersion}`) - console.log(`Version type: ${detectVersionType(newVersion)}`) - break - } - - case 'major': - case 'minor': - case 'patch': { - const versionType = (args[1] as VersionType) || 'stable' - const incrementedVersion = incrementVersion(currentVersion, command, versionType) - packageData.version = incrementedVersion - writePackageJson(packageData) - console.log(`Version updated from ${currentVersion} to ${incrementedVersion}`) - console.log(`Version type: ${detectVersionType(incrementedVersion)}`) - break - } - - case 'prerelease': { - const prereleaseVersion = incrementPrerelease(currentVersion) - packageData.version = prereleaseVersion - writePackageJson(packageData) - console.log(`Version updated from ${currentVersion} to ${prereleaseVersion}`) - console.log(`Version type: ${detectVersionType(prereleaseVersion)}`) - break - } - - default: { - console.error(`Unknown command: ${command}`) - process.exit(1) - } - } - } catch (error) { - console.error(`Error: ${(error as Error).message}`) - process.exit(1) - } -} - -// Always run main function when script is executed directly -main() - -export { - detectVersionType, - formatVersion, - incrementPrerelease, - type IncrementType, - incrementVersion, - type PackageJson, - type ParsedVersion, - parseVersion, - type VersionType -} diff --git a/src/main/__tests__/ipc.database.test.ts b/src/main/__tests__/ipc.database.test.ts index 1ac4f324..4793ad42 100644 --- a/src/main/__tests__/ipc.database.test.ts +++ b/src/main/__tests__/ipc.database.test.ts @@ -166,7 +166,17 @@ vi.mock('../services/FFmpegService', () => ({ getVideoInfo: vi.fn(), transcodeVideo: vi.fn(), cancelTranscode: vi.fn(), - getFFmpegPath: vi.fn() + getFFmpegPath: vi.fn(), + getDownloadService: vi.fn(() => ({ + checkFFmpegExists: vi.fn(), + getFFmpegVersion: vi.fn(), + downloadFFmpeg: vi.fn(), + getDownloadProgress: vi.fn(), + cancelDownload: vi.fn(), + removeFFmpeg: vi.fn(), + getAllSupportedVersions: vi.fn(), + cleanupTempFiles: vi.fn() + })) })) })) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 50043a5b..26ff54ef 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -474,6 +474,51 @@ export function registerIpc(mainWindow: BrowserWindow, app: Electron.App) { ipcMain.handle(IpcChannel.Ffmpeg_GetWarmupStatus, async () => { return FFmpegService.getWarmupStatus() }) + ipcMain.handle(IpcChannel.Ffmpeg_GetInfo, async () => { + return ffmpegService.getFFmpegInfo() + }) + ipcMain.handle(IpcChannel.Ffmpeg_AutoDetectAndDownload, async () => { + return await ffmpegService.autoDetectAndDownload() + }) + + // FFmpeg 下载服务 + const ffmpegDownloadService = ffmpegService.getDownloadService() + ipcMain.handle( + IpcChannel.FfmpegDownload_CheckExists, + async (_, platform?: string, arch?: string) => { + return ffmpegDownloadService.checkFFmpegExists(platform as any, arch as any) + } + ) + ipcMain.handle( + IpcChannel.FfmpegDownload_GetVersion, + async (_, platform?: string, arch?: string) => { + return ffmpegDownloadService.getFFmpegVersion(platform as any, arch as any) + } + ) + ipcMain.handle( + IpcChannel.FfmpegDownload_Download, + async (_, platform?: string, arch?: string) => { + return await ffmpegDownloadService.downloadFFmpeg(platform as any, arch as any) + } + ) + ipcMain.handle( + IpcChannel.FfmpegDownload_GetProgress, + async (_, platform?: string, arch?: string) => { + return ffmpegDownloadService.getDownloadProgress(platform as any, arch as any) + } + ) + ipcMain.handle(IpcChannel.FfmpegDownload_Cancel, async (_, platform?: string, arch?: string) => { + return ffmpegDownloadService.cancelDownload(platform as any, arch as any) + }) + ipcMain.handle(IpcChannel.FfmpegDownload_Remove, async (_, platform?: string, arch?: string) => { + return ffmpegDownloadService.removeFFmpeg(platform as any, arch as any) + }) + ipcMain.handle(IpcChannel.FfmpegDownload_GetAllVersions, async () => { + return ffmpegDownloadService.getAllSupportedVersions() + }) + ipcMain.handle(IpcChannel.FfmpegDownload_CleanupTemp, async () => { + return ffmpegDownloadService.cleanupTempFiles() + }) // MediaParser (Remotion) ipcMain.handle(IpcChannel.MediaInfo_CheckExists, async () => { diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index 611ae76a..6fcae5a2 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -79,7 +79,7 @@ export default class AppUpdater { try { logger.info('get pre release version from github', channel) const responses = await fetch( - 'https://api.github.com/repos/mkdir700/EchoPlayer/releases?per_page=8', + 'https://api.github.com/repos/mkdir700/EchoPlayer/releases?per_page=20', { headers: { Accept: 'application/vnd.github+json', @@ -89,12 +89,25 @@ export default class AppUpdater { } ) const data = (await responses.json()) as GithubReleaseInfo[] - const release: GithubReleaseInfo | undefined = data.find((item: GithubReleaseInfo) => { + + // 过滤出匹配渠道的预发布版本 + const matchingReleases = data.filter((item: GithubReleaseInfo) => { return item.prerelease && item.tag_name.includes(`-${channel}.`) }) - logger.info('release info', release) - return release ? release : null + if (matchingReleases.length === 0) { + logger.info('No matching pre-release found for channel:', channel) + return null + } + + // 按发布时间排序,获取最新的版本 + const release = matchingReleases.sort( + (a, b) => + new Date(b.published_at || '').getTime() - new Date(a.published_at || '').getTime() + )[0] + + logger.info('Latest release info for channel', channel, ':', release) + return release } catch (error) { logger.error('Failed to get latest not draft version from github:', error) return null @@ -274,21 +287,13 @@ export default class AppUpdater { if (!this.releaseInfo) { return } - // const locale = locales[configManager.getLanguage()] - // const { update: updateLocale } = locale.translation - - let detail = this.formatReleaseNotes(this.releaseInfo.releaseNotes) - if (detail === '') { - detail = 'No release notes' - } dialog .showMessageBox({ type: 'info', title: 'Update available', icon, - message: 'A new version is available. Do you want to download it now?', - detail, + message: `A new version (${this.releaseInfo.version}) is available. Do you want to install it now?\n\nYou can view the release notes in Settings > About.`, buttons: ['Later', 'Install'], defaultId: 1, cancelId: 0 @@ -302,18 +307,6 @@ export default class AppUpdater { } }) } - - private formatReleaseNotes(releaseNotes: string | ReleaseNoteInfo[] | null | undefined): string { - if (!releaseNotes) { - return '' - } - - if (typeof releaseNotes === 'string') { - return releaseNotes - } - - return releaseNotes.map((note) => note.note).join('\n') - } } interface GithubReleaseInfo { id: number @@ -335,7 +328,3 @@ interface GithubReleaseInfo { created_at: string }> } -interface ReleaseNoteInfo { - readonly version: string - readonly note: string | null -} diff --git a/src/main/services/FFmpegDownloadService.ts b/src/main/services/FFmpegDownloadService.ts new file mode 100644 index 00000000..b9e02729 --- /dev/null +++ b/src/main/services/FFmpegDownloadService.ts @@ -0,0 +1,793 @@ +import { spawn } from 'child_process' +// import * as crypto from 'crypto' // TODO: 将来用于 SHA256 校验 +import { app } from 'electron' +import * as fs from 'fs' +import * as https from 'https' +import * as path from 'path' + +import { loggerService } from './LoggerService' + +const logger = loggerService.withContext('FFmpegDownloadService') + +// 支持的平台类型 +export type Platform = 'win32' | 'darwin' | 'linux' +export type Arch = 'x64' | 'arm64' + +// FFmpeg 版本配置接口 +export interface FFmpegVersion { + version: string + platform: Platform + arch: Arch + url: string + sha256?: string + size: number + extractPath?: string // 解压后的相对路径 +} + +// 下载进度接口 +export interface DownloadProgress { + percent: number + downloaded: number + total: number + speed: number + remainingTime: number + status: 'downloading' | 'extracting' | 'verifying' | 'completed' | 'error' +} + +// 下载状态枚举 +export enum DownloadStatus { + NOT_STARTED = 'not_started', + DOWNLOADING = 'downloading', + EXTRACTING = 'extracting', + VERIFYING = 'verifying', + COMPLETED = 'completed', + ERROR = 'error', + CANCELLED = 'cancelled' +} + +// FFmpeg 配置 - 使用稳定版本 +const FFMPEG_VERSIONS: Record> = { + win32: { + x64: { + version: '6.1', + platform: 'win32', + arch: 'x64', + url: 'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-win64-gpl.zip', + size: 89 * 1024 * 1024, // 约 89MB + extractPath: 'ffmpeg-master-latest-win64-gpl/bin/ffmpeg.exe' + }, + arm64: { + version: '6.1', + platform: 'win32', + arch: 'arm64', + url: 'https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-winarm64-gpl.zip', + size: 85 * 1024 * 1024, // 约 85MB + extractPath: 'ffmpeg-master-latest-winarm64-gpl/bin/ffmpeg.exe' + } + }, + darwin: { + x64: { + version: '6.1', + platform: 'darwin', + arch: 'x64', + url: 'https://evermeet.cx/ffmpeg/ffmpeg-6.1.zip', + size: 67 * 1024 * 1024, // 约 67MB + extractPath: 'ffmpeg' + }, + arm64: { + version: '6.1', + platform: 'darwin', + arch: 'arm64', + url: 'https://evermeet.cx/ffmpeg/ffmpeg-6.1.zip', + size: 67 * 1024 * 1024, // 约 67MB + extractPath: 'ffmpeg' + } + }, + linux: { + x64: { + version: '6.1', + platform: 'linux', + arch: 'x64', + url: 'https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz', + size: 35 * 1024 * 1024, // 约 35MB + extractPath: 'ffmpeg-*-amd64-static/ffmpeg' + }, + arm64: { + version: '6.1', + platform: 'linux', + arch: 'arm64', + url: 'https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-arm64-static.tar.xz', + size: 33 * 1024 * 1024, // 约 33MB + extractPath: 'ffmpeg-*-arm64-static/ffmpeg' + } + } +} + +// 中国区专供的 FFmpeg 配置 +const CHINA_FFMPEG_VERSIONS: Record> = { + win32: { + x64: { + version: '6.1', + platform: 'win32', + arch: 'x64', + url: 'https://gitcode.com/mkdir700/echoplayer-ffmpeg/releases/download/v0.0.0/win32-x64.zip', + size: 60 * 1024 * 1024, + extractPath: 'win32-x64/ffmpeg.exe' + }, + arm64: { + version: '6.1', + platform: 'win32', + arch: 'arm64', + url: 'https://gitcode.com/mkdir700/echoplayer-ffmpeg/releases/download/v0.0.0/win32-arm64.zip', + size: 45 * 1024 * 1024, + extractPath: 'win32-arm64/ffmpeg.exe' + } + }, + darwin: { + x64: { + version: '6.1', + platform: 'darwin', + arch: 'x64', + url: 'https://gitcode.com/mkdir700/echoplayer-ffmpeg/releases/download/v0.0.0/darwin-x64.zip', + size: 24 * 1024 * 1024, + extractPath: 'darwin-x64/ffmpeg' + }, + arm64: { + version: '6.1', + platform: 'darwin', + arch: 'arm64', + url: 'https://gitcode.com/mkdir700/echoplayer-ffmpeg/releases/download/v0.0.0/darwin-arm64.zip', + size: 24 * 1024 * 1024, + extractPath: 'darwin-arm64/ffmpeg' + } + }, + linux: { + x64: { + version: '6.1', + platform: 'linux', + arch: 'x64', + url: 'https://gitcode.com/mkdir700/echoplayer-ffmpeg/releases/download/v0.0.0/linux-x64.zip', + size: 28 * 1024 * 1024, + extractPath: 'linux-x64/ffmpeg' + }, + arm64: { + version: '6.1', + platform: 'linux', + arch: 'arm64', + url: 'https://gitcode.com/mkdir700/echoplayer-ffmpeg/releases/download/v0.0.0/linux-arm64.zip', + size: 24 * 1024 * 1024, + extractPath: 'linux-arm64/ffmpeg' + } + } +} + +export class FFmpegDownloadService { + private downloadProgress = new Map() + private downloadController = new Map() + private readonly binariesDir: string + private useChinaMirror: boolean = false + private regionDetectionPromise: Promise | null = null + + constructor() { + // FFmpeg 存储在 userData/binaries/ffmpeg/ 目录 + this.binariesDir = path.join(app.getPath('userData'), 'binaries', 'ffmpeg') + this.ensureDir(this.binariesDir) + // 异步检测地区并设置镜像源(不阻塞初始化) + this.regionDetectionPromise = this.detectRegionAndSetMirror() + } + + /** + * 通过 IP 地理位置检测用户地区并设置镜像源 + */ + private async detectRegionAndSetMirror(): Promise { + try { + const country = await this.getIpCountry() + + // 中国大陆、香港、澳门、台湾用户都使用中国镜像源 + const chineseRegions = ['cn', 'hk', 'mo', 'tw'] + this.useChinaMirror = chineseRegions.includes(country?.toLowerCase() || '') + + logger.info('通过IP检测地区,设置镜像源', { + country, + useChinaMirror: this.useChinaMirror + }) + } catch (error) { + logger.warn('无法检测用户地区,使用默认镜像源', { error }) + this.useChinaMirror = true // 检测失败时默认使用中国镜像源 + } + } + + /** + * 获取用户IP对应的国家代码 + */ + private async getIpCountry(): Promise { + try { + // 使用 AbortController 设置 5 秒超时 + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 5000) + + const response = await fetch('https://ipinfo.io/json', { + signal: controller.signal, + headers: { + 'User-Agent': 'EchoPlayer-FFmpeg-Downloader/2.0', + 'Accept-Language': 'en-US,en;q=0.9' + } + }) + + clearTimeout(timeoutId) + const data = await response.json() + return data.country || 'CN' // 默认返回 CN,这样中国用户即使检测失败也能使用中国镜像源 + } catch (error) { + logger.warn('获取IP地理位置失败,默认使用中国镜像源', { error }) + return 'CN' // 默认返回 CN + } + } + + /** + * 手动设置镜像源 + */ + public setMirrorSource(useChina: boolean): void { + this.useChinaMirror = useChina + logger.info('手动设置镜像源', { useChinaMirror: this.useChinaMirror }) + } + + /** + * 获取当前使用的镜像源 + */ + public getCurrentMirrorSource(): 'china' | 'global' { + return this.useChinaMirror ? 'china' : 'global' + } + + /** + * 获取 FFmpeg 在本地的存储路径 + */ + public getFFmpegPath( + platform = process.platform as Platform, + arch = process.arch as Arch + ): string { + const version = this.getFFmpegVersion(platform, arch) + if (!version) { + throw new Error(`不支持的平台: ${platform}-${arch}`) + } + + const platformDir = `${version.version}-${platform}-${arch}` + const executableName = platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' + return path.join(this.binariesDir, platformDir, executableName) + } + + /** + * 检查 FFmpeg 是否已下载 + */ + public checkFFmpegExists( + platform = process.platform as Platform, + arch = process.arch as Arch + ): boolean { + try { + const ffmpegPath = this.getFFmpegPath(platform, arch) + return fs.existsSync(ffmpegPath) && fs.statSync(ffmpegPath).isFile() + } catch { + return false + } + } + + /** + * 获取 FFmpeg 版本配置 + */ + public getFFmpegVersion( + platform = process.platform as Platform, + arch = process.arch as Arch + ): FFmpegVersion | null { + // 优先使用中国镜像源(如果启用) + if (this.useChinaMirror) { + const chinaVersion = CHINA_FFMPEG_VERSIONS[platform]?.[arch] + if (chinaVersion) { + return chinaVersion + } + logger.warn('中国镜像源不支持当前平台,回退到全球镜像源', { platform, arch }) + } + + // 回退到全球镜像源 + return FFMPEG_VERSIONS[platform]?.[arch] || null + } + + /** + * 获取所有支持的平台配置 + */ + public getAllSupportedVersions(): FFmpegVersion[] { + const versions: FFmpegVersion[] = [] + + // 添加当前镜像源的版本 + const currentVersions = this.useChinaMirror ? CHINA_FFMPEG_VERSIONS : FFMPEG_VERSIONS + for (const platformConfigs of Object.values(currentVersions)) { + for (const version of Object.values(platformConfigs)) { + versions.push(version) + } + } + + return versions + } + + /** + * 获取指定镜像源的所有支持版本 + */ + public getAllVersionsByMirror(mirrorType: 'china' | 'global'): FFmpegVersion[] { + const versions: FFmpegVersion[] = [] + const versionConfigs = mirrorType === 'china' ? CHINA_FFMPEG_VERSIONS : FFMPEG_VERSIONS + + for (const platformConfigs of Object.values(versionConfigs)) { + for (const version of Object.values(platformConfigs)) { + versions.push(version) + } + } + + return versions + } + + /** + * 开始下载 FFmpeg + */ + public async downloadFFmpeg( + platform = process.platform as Platform, + arch = process.arch as Arch, + onProgress?: (progress: DownloadProgress) => void + ): Promise { + const key = `${platform}-${arch}` + + // 检查是否已存在 + if (this.checkFFmpegExists(platform, arch)) { + logger.info('FFmpeg 已存在,跳过下载', { platform, arch }) + return true + } + + // 检查是否正在下载 + if (this.downloadProgress.has(key)) { + logger.warn('FFmpeg 正在下载中', { platform, arch }) + return false + } + + // 尝试下载(如果中国镜像源失败会自动回退) + return await this.downloadFFmpegWithFallback(platform, arch, onProgress) + } + + /** + * 带回退机制的下载方法 + */ + private async downloadFFmpegWithFallback( + platform: Platform, + arch: Arch, + onProgress?: (progress: DownloadProgress) => void + ): Promise { + // 等待地区检测完成(最多等待 10 秒) + if (this.regionDetectionPromise) { + try { + await Promise.race([ + this.regionDetectionPromise, + new Promise((_, reject) => setTimeout(() => reject(new Error('地区检测超时')), 10000)) + ]) + } catch (error) { + logger.warn('地区检测超时或失败,使用当前镜像源设置', { error }) + } + } + + // 首先尝试当前镜像源 + const version = this.getFFmpegVersion(platform, arch) + if (!version) { + logger.error('不支持的平台', { platform, arch }) + return false + } + + logger.info('开始下载 FFmpeg', { + platform, + arch, + version: version.version, + mirrorSource: this.getCurrentMirrorSource(), + url: version.url + }) + + // 尝试下载 + let success = await this.performDownload(platform, arch, version, onProgress) + + // 如果使用中国镜像源失败,自动回退到全球镜像源 + if (!success && this.useChinaMirror) { + logger.warn('中国镜像源下载失败,尝试回退到全球镜像源', { platform, arch }) + + const globalVersion = FFMPEG_VERSIONS[platform]?.[arch] + if (globalVersion) { + logger.info('使用全球镜像源重新下载', { + platform, + arch, + url: globalVersion.url + }) + success = await this.performDownload(platform, arch, globalVersion, onProgress) + } + } + + return success + } + + /** + * 执行实际的下载操作 + */ + private async performDownload( + platform: Platform, + arch: Arch, + version: FFmpegVersion, + onProgress?: (progress: DownloadProgress) => void + ): Promise { + const key = `${platform}-${arch}` + const controller = new AbortController() + this.downloadController.set(key, controller) + + const progress: DownloadProgress = { + percent: 0, + downloaded: 0, + total: version.size, + speed: 0, + remainingTime: 0, + status: 'downloading' + } + + this.downloadProgress.set(key, progress) + + try { + // 创建目标目录 + const platformDir = `${version.version}-${platform}-${arch}` + const targetDir = path.join(this.binariesDir, platformDir) + const tempDir = path.join(this.binariesDir, '.temp', key) + + this.ensureDir(targetDir) + this.ensureDir(tempDir) + + // 下载文件 + const downloadPath = path.join(tempDir, path.basename(version.url)) + await this.downloadFile( + version.url, + downloadPath, + (percent, downloaded, total, speed) => { + progress.percent = percent + progress.downloaded = downloaded + progress.total = total + progress.speed = speed + progress.remainingTime = speed > 0 ? (total - downloaded) / speed : 0 + onProgress?.(progress) + }, + controller.signal + ) + + // 解压文件 + progress.status = 'extracting' + progress.percent = 90 + onProgress?.(progress) + + await this.extractFile(downloadPath, tempDir) + + // 移动到目标位置 + const executableName = platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' + const finalPath = path.join(targetDir, executableName) + + let extractedPath: string + if (version.extractPath?.includes('*')) { + extractedPath = await this.findFile(tempDir, path.basename(version.extractPath)) + if (!extractedPath) { + throw new Error('未找到可执行文件') + } + } else { + extractedPath = path.join(tempDir, version.extractPath || executableName) + } + + fs.copyFileSync(extractedPath, finalPath) + + // 设置执行权限 + if (platform !== 'win32') { + fs.chmodSync(finalPath, 0o755) + } + + // 完成 + progress.status = 'completed' + progress.percent = 100 + onProgress?.(progress) + + logger.info('FFmpeg 下载完成', { platform, arch, finalPath }) + + // 清理临时文件 + this.cleanupTempDir(tempDir) + + return true + } catch (error) { + progress.status = 'error' + onProgress?.(progress) + + logger.error('FFmpeg 下载失败', { + platform, + arch, + error: error instanceof Error ? error.message : String(error) + }) + + return false + } finally { + this.downloadProgress.delete(key) + this.downloadController.delete(key) + } + } + + /** + * 取消下载 + */ + public cancelDownload( + platform = process.platform as Platform, + arch = process.arch as Arch + ): void { + const key = `${platform}-${arch}` + const controller = this.downloadController.get(key) + if (controller) { + controller.abort() + logger.info('取消 FFmpeg 下载', { platform, arch }) + } + } + + /** + * 获取下载进度 + */ + public getDownloadProgress( + platform = process.platform as Platform, + arch = process.arch as Arch + ): DownloadProgress | null { + const key = `${platform}-${arch}` + return this.downloadProgress.get(key) || null + } + + /** + * 删除已下载的 FFmpeg + */ + public removeFFmpeg( + platform = process.platform as Platform, + arch = process.arch as Arch + ): boolean { + try { + const version = this.getFFmpegVersion(platform, arch) + if (!version) return false + + const platformDir = `${version.version}-${platform}-${arch}` + const targetDir = path.join(this.binariesDir, platformDir) + + if (fs.existsSync(targetDir)) { + fs.rmSync(targetDir, { recursive: true, force: true }) + logger.info('删除 FFmpeg 成功', { platform, arch }) + return true + } + + return false + } catch (error) { + logger.error('删除 FFmpeg 失败', { + platform, + arch, + error: error instanceof Error ? error.message : String(error) + }) + return false + } + } + + /** + * 清理所有临时文件 + */ + public cleanupTempFiles(): void { + const tempDir = path.join(this.binariesDir, '.temp') + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }) + logger.info('清理临时文件完成') + } + } + + // 私有方法 + + private ensureDir(dir: string): void { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }) + } + } + + private async downloadFile( + url: string, + outputPath: string, + onProgress?: (percent: number, downloaded: number, total: number, speed: number) => void, + signal?: AbortSignal + ): Promise { + return new Promise((resolve, reject) => { + const file = fs.createWriteStream(outputPath) + let downloadedSize = 0 + let totalSize = 0 + const startTime = Date.now() + let lastTime = startTime + let lastDownloaded = 0 + + const download = (currentUrl: string, redirectCount = 0): void => { + if (redirectCount > 5) { + reject(new Error('重定向次数过多')) + return + } + + const request = https.get( + currentUrl, + { + headers: { + 'User-Agent': 'EchoPlayer-FFmpeg-Downloader/2.0' + }, + timeout: 30000 + }, + (response) => { + // 处理重定向 + if (response.statusCode === 301 || response.statusCode === 302) { + const redirectUrl = response.headers.location + if (redirectUrl) { + download(redirectUrl, redirectCount + 1) + return + } + } + + if (response.statusCode !== 200) { + reject(new Error(`下载失败: HTTP ${response.statusCode}`)) + return + } + + totalSize = parseInt(response.headers['content-length'] || '0', 10) + + response.on('data', (chunk) => { + if (signal?.aborted) { + response.destroy() + file.destroy() + fs.unlink(outputPath, () => {}) + reject(new Error('下载已取消')) + return + } + + downloadedSize += chunk.length + + // 计算下载速度 + const now = Date.now() + if (now - lastTime > 1000) { + // 每秒更新一次 + const timeDiff = (now - lastTime) / 1000 + const sizeDiff = downloadedSize - lastDownloaded + const speed = sizeDiff / timeDiff + + if (onProgress && totalSize > 0) { + onProgress((downloadedSize / totalSize) * 100, downloadedSize, totalSize, speed) + } + + lastTime = now + lastDownloaded = downloadedSize + } + }) + + response.pipe(file) + + file.on('finish', () => { + file.close() + resolve() + }) + + file.on('error', (err) => { + fs.unlink(outputPath, () => {}) + reject(err) + }) + + response.on('error', reject) + } + ) + + request.on('error', reject) + request.on('timeout', () => { + request.destroy() + reject(new Error('下载超时')) + }) + + // 监听取消信号 + signal?.addEventListener('abort', () => { + request.destroy() + }) + } + + download(url) + }) + } + + private async extractFile(archivePath: string, extractDir: string): Promise { + if (archivePath.endsWith('.zip')) { + await this.extractZip(archivePath, extractDir) + } else if (archivePath.endsWith('.tar.xz')) { + await this.extractTarXz(archivePath, extractDir) + } else { + throw new Error('不支持的压缩格式') + } + } + + private async extractZip(zipPath: string, extractDir: string): Promise { + return new Promise((resolve, reject) => { + let command: string + let args: string[] + + if (process.platform === 'win32') { + command = 'powershell' + args = [ + '-Command', + `Expand-Archive -Path "${zipPath}" -DestinationPath "${extractDir}" -Force` + ] + } else { + command = 'unzip' + args = ['-o', zipPath, '-d', extractDir] + } + + const child = spawn(command, args, { stdio: 'pipe' }) + + child.on('close', (code) => { + if (code === 0) { + resolve() + } else { + reject(new Error(`解压失败,退出代码: ${code}`)) + } + }) + + child.on('error', reject) + }) + } + + private async extractTarXz(tarPath: string, extractDir: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn('tar', ['-xJf', tarPath, '-C', extractDir], { stdio: 'pipe' }) + + child.on('close', (code) => { + if (code === 0) { + resolve() + } else { + reject(new Error(`解压失败,退出代码: ${code}`)) + } + }) + + child.on('error', reject) + }) + } + + private async findFile(dir: string, pattern: string): Promise { + const items = await fs.promises.readdir(dir, { withFileTypes: true }) + + for (const item of items) { + const fullPath = path.join(dir, item.name) + + if (item.isDirectory()) { + try { + const found = await this.findFile(fullPath, pattern) + if (found) return found + } catch { + // 继续搜索其他目录 + } + } else if (item.isFile()) { + if (pattern.includes('*')) { + const regex = new RegExp(pattern.replace(/\*/g, '.*')) + if (regex.test(item.name)) { + return fullPath + } + } else if (item.name === pattern) { + return fullPath + } + } + } + + throw new Error(`未找到文件: ${pattern}`) + } + + private cleanupTempDir(tempDir: string): void { + try { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }) + } + } catch (error) { + logger.warn('清理临时目录失败', { + tempDir, + error: error instanceof Error ? error.message : String(error) + }) + } + } +} + +// 导出单例实例 +export const ffmpegDownloadService = new FFmpegDownloadService() diff --git a/src/main/services/FFmpegService.ts b/src/main/services/FFmpegService.ts index 8b264ad6..81fae394 100644 --- a/src/main/services/FFmpegService.ts +++ b/src/main/services/FFmpegService.ts @@ -4,6 +4,7 @@ import { app } from 'electron' import * as fs from 'fs' import * as path from 'path' +import { ffmpegDownloadService } from './FFmpegDownloadService' import { loggerService } from './LoggerService' const logger = loggerService.withContext('FFmpegService') @@ -183,13 +184,20 @@ class FFmpegService { // 获取 FFmpeg 可执行文件路径 public getFFmpegPath(): string { - // 1. 优先使用内置的 FFmpeg + // 1. 优先使用内置的 FFmpeg(向后兼容) const bundledPath = this.getBundledFFmpegPath() if (bundledPath) { return bundledPath } - // 2. 降级到系统 FFmpeg + // 2. 检查动态下载的 FFmpeg + if (ffmpegDownloadService.checkFFmpegExists()) { + const downloadedPath = ffmpegDownloadService.getFFmpegPath() + logger.info('使用动态下载的 FFmpeg', { downloadedPath }) + return downloadedPath + } + + // 3. 降级到系统 FFmpeg const platform = process.platform as keyof typeof this.FFMPEG_EXEC_NAMES const executable = this.FFMPEG_EXEC_NAMES[platform]?.executable || 'ffmpeg' @@ -202,19 +210,36 @@ class FFmpegService { return this.getBundledFFmpegPath() !== null } + // 检查是否正在使用动态下载的 FFmpeg + public isUsingDownloadedFFmpeg(): boolean { + return !this.isUsingBundledFFmpeg() && ffmpegDownloadService.checkFFmpegExists() + } + // 获取 FFmpeg 信息 public getFFmpegInfo(): { path: string isBundled: boolean + isDownloaded: boolean + isSystemFFmpeg: boolean platform: string arch: string + version?: string + needsDownload: boolean } { const bundledPath = this.getBundledFFmpegPath() + const isDownloaded = ffmpegDownloadService.checkFFmpegExists() + const isBundled = bundledPath !== null + const isSystemFFmpeg = !isBundled && !isDownloaded + return { - path: bundledPath || this.getFFmpegPath(), - isBundled: bundledPath !== null, + path: this.getFFmpegPath(), + isBundled, + isDownloaded, + isSystemFFmpeg, platform: process.platform, - arch: process.arch + arch: process.arch, + version: ffmpegDownloadService.getFFmpegVersion()?.version, + needsDownload: !isBundled && !isDownloaded } } @@ -502,6 +527,15 @@ class FFmpegService { private async executeFFmpegDirect(args: string[], timeout: number): Promise { return new Promise((resolve, reject) => { const ffmpegPath = this.getFFmpegPath() + const ffmpegInfo = this.getFFmpegInfo() + + logger.info('🎬 执行 FFmpeg 命令', { + ffmpegPath, + args: args.slice(0, 3), // 只显示前3个参数避免日志过长 + isSystemFFmpeg: ffmpegInfo.isSystemFFmpeg, + needsDownload: ffmpegInfo.needsDownload + }) + const ffmpeg = spawn(ffmpegPath, args) let output = '' @@ -541,7 +575,24 @@ class FFmpegService { ffmpeg.on('error', (error) => { clearTimeout(timeoutHandle) if (!hasTimedOut) { - reject(error) + // 检查是否是 ENOENT 错误(文件不存在) + if ((error as any).code === 'ENOENT') { + const errorMessage = ffmpegInfo.needsDownload + ? `FFmpeg 未找到。您需要下载 FFmpeg 才能处理视频文件。\n\n建议操作:\n1. 打开应用设置\n2. 在 "插件管理" 中下载 FFmpeg\n3. 或手动安装系统 FFmpeg\n\n技术信息:${error.message}` + : `FFmpeg 不可用:${error.message}\n\n请检查 FFmpeg 安装或联系技术支持。` + + logger.error('❌ FFmpeg 执行失败 - 文件不存在', { + ffmpegPath, + needsDownload: ffmpegInfo.needsDownload, + isSystemFFmpeg: ffmpegInfo.isSystemFFmpeg, + platform: process.platform, + error: error.message + }) + + reject(new Error(errorMessage)) + } else { + reject(error) + } } }) }) @@ -635,6 +686,55 @@ class FFmpegService { } } + /** + * 自动检测并下载 FFmpeg + * 如果没有内置版本且本地也没有下载版本,则触发下载 + */ + public async autoDetectAndDownload(): Promise<{ + available: boolean + needsDownload: boolean + downloadTriggered: boolean + }> { + const info = this.getFFmpegInfo() + + // 如果已有可用的 FFmpeg(内置或下载版本),直接返回 + if (info.isBundled || info.isDownloaded) { + return { + available: true, + needsDownload: false, + downloadTriggered: false + } + } + + // 检查系统 FFmpeg + if (await this.checkFFmpegExists()) { + return { + available: true, + needsDownload: false, + downloadTriggered: false + } + } + + // 需要下载 + logger.info('检测到需要下载 FFmpeg', { + platform: process.platform, + arch: process.arch + }) + + return { + available: false, + needsDownload: true, + downloadTriggered: false + } + } + + /** + * 获取动态下载服务实例 + */ + public getDownloadService() { + return ffmpegDownloadService + } + /** * 销毁服务,清理资源 */ @@ -650,6 +750,9 @@ class FFmpegService { // 重置预热状态 FFmpegService.resetWarmupState() + // 清理下载服务的临时文件 + ffmpegDownloadService.cleanupTempFiles() + logger.info('FFmpeg 服务已销毁') } } diff --git a/src/main/services/ThemeService.ts b/src/main/services/ThemeService.ts index 901a0796..4bd781c8 100644 --- a/src/main/services/ThemeService.ts +++ b/src/main/services/ThemeService.ts @@ -3,6 +3,7 @@ import { ThemeMode } from '@types' import { BrowserWindow, nativeTheme } from 'electron' import { titleBarOverlayDark, titleBarOverlayLight } from '../config' +import { isMac } from '../constant' import { configManager } from './ConfigManager' class ThemeService { @@ -26,7 +27,8 @@ class ThemeService { themeUpdatadHandler() { BrowserWindow.getAllWindows().forEach((win) => { - if (win && !win.isDestroyed() && win.setTitleBarOverlay) { + // 只对 macOS 应用 titleBarOverlay,因为 Windows 和 Linux 使用系统标题栏 + if (isMac && win && !win.isDestroyed() && win.setTitleBarOverlay) { try { win.setTitleBarOverlay( nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 5eae94b3..9dc9da7b 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -56,6 +56,26 @@ export class WindowService { maximize: false }) + // 平台特定的标题栏配置 + const getTitleBarConfig = () => { + if (isWin || isLinux) { + // Windows 和 Linux 使用系统标题栏 + return { + titleBarStyle: undefined + // 不设置 titleBarOverlay 和 trafficLightPosition + } + } else { + // macOS 保持自定义标题栏 + return { + titleBarStyle: 'hidden' as const, + titleBarOverlay: nativeTheme.shouldUseDarkColors + ? titleBarOverlayDark + : titleBarOverlayLight, + trafficLightPosition: { x: 8, y: 13 } + } + } + } + this.mainWindow = new BrowserWindow({ x: mainWindowState.x, y: mainWindowState.y, @@ -68,11 +88,9 @@ export class WindowService { transparent: false, vibrancy: 'sidebar', visualEffectState: 'active', - titleBarStyle: 'hidden', - titleBarOverlay: nativeTheme.shouldUseDarkColors ? titleBarOverlayDark : titleBarOverlayLight, + ...getTitleBarConfig(), backgroundColor: isMac ? undefined : nativeTheme.shouldUseDarkColors ? '#181818' : '#FFFFFF', darkTheme: nativeTheme.shouldUseDarkColors, - trafficLightPosition: { x: 8, y: 13 }, ...(isLinux ? { icon } : {}), webPreferences: { preload: join(__dirname, '../preload/index.js'), diff --git a/src/main/services/__tests__/FFmpegDownloadService.test.ts b/src/main/services/__tests__/FFmpegDownloadService.test.ts new file mode 100644 index 00000000..2d3243f7 --- /dev/null +++ b/src/main/services/__tests__/FFmpegDownloadService.test.ts @@ -0,0 +1,491 @@ +import { app } from 'electron' +import * as fs from 'fs' +import * as path from 'path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { FFmpegDownloadService } from '../FFmpegDownloadService' + +// Mock modules +vi.mock('fs') +vi.mock('path') +vi.mock('electron', () => ({ + app: { + getPath: vi.fn() + } +})) +vi.mock('../LoggerService', () => ({ + loggerService: { + withContext: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() + }) + } +})) +vi.mock('https') +vi.mock('child_process') + +// Mock global fetch for IP detection tests +global.fetch = vi.fn() + +describe('FFmpegDownloadService', () => { + let service: FFmpegDownloadService + const mockUserDataPath = '/mock/user/data' + + beforeEach(() => { + vi.clearAllMocks() + + // Mock app.getPath + vi.mocked(app.getPath).mockReturnValue(mockUserDataPath) + + // Mock fs.existsSync + vi.mocked(fs.existsSync).mockReturnValue(true) + + // Mock fs.mkdirSync + vi.mocked(fs.mkdirSync).mockReturnValue(undefined) + + // Mock path.join to return predictable paths + vi.mocked(path.join).mockImplementation((...args) => args.join('/')) + + service = new FFmpegDownloadService() + }) + + describe('getFFmpegPath', () => { + it('should return correct path for Windows x64', () => { + const result = service.getFFmpegPath('win32', 'x64') + expect(result).toMatch(/6\.1-win32-x64[\\/]ffmpeg\.exe$/) + }) + + it('should return correct path for macOS arm64', () => { + const result = service.getFFmpegPath('darwin', 'arm64') + expect(result).toMatch(/6\.1-darwin-arm64[\\/]ffmpeg$/) + }) + + it('should return correct path for Linux x64', () => { + const result = service.getFFmpegPath('linux', 'x64') + expect(result).toMatch(/6\.1-linux-x64[\\/]ffmpeg$/) + }) + + it('should throw error for unsupported platform', () => { + expect(() => service.getFFmpegPath('unsupported' as any, 'x64')).toThrow('不支持的平台') + }) + }) + + describe('checkFFmpegExists', () => { + it('should return true when FFmpeg file exists', () => { + // Mock path.join to return a predictable path + vi.mocked(path.join).mockReturnValue('/mock/ffmpeg/path') + + // Mock fs.existsSync to return true + vi.mocked(fs.existsSync).mockReturnValue(true) + + // Mock fs.statSync to return a file + vi.mocked(fs.statSync).mockReturnValue({ isFile: () => true } as any) + + const result = service.checkFFmpegExists('win32', 'x64') + expect(result).toBe(true) + }) + + it('should return false when FFmpeg file does not exist', () => { + vi.mocked(fs.existsSync).mockReturnValue(false) + + const result = service.checkFFmpegExists('win32', 'x64') + expect(result).toBe(false) + }) + + it('should return false when path exists but is not a file', () => { + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.statSync).mockReturnValue({ isFile: () => false } as any) + + const result = service.checkFFmpegExists('win32', 'x64') + expect(result).toBe(false) + }) + }) + + describe('getFFmpegVersion', () => { + it('should return version config for supported platforms', () => { + // 由于现在默认使用中国镜像源,我们需要明确设置镜像源来测试 + service.setMirrorSource(false) // 设置为全球镜像源 + + const winVersion = service.getFFmpegVersion('win32', 'x64') + expect(winVersion).toMatchObject({ + version: '6.1', + platform: 'win32', + arch: 'x64', + url: expect.stringContaining('ffmpeg-master-latest-win64-gpl.zip') + }) + + const macVersion = service.getFFmpegVersion('darwin', 'arm64') + expect(macVersion).toMatchObject({ + version: '6.1', + platform: 'darwin', + arch: 'arm64', + url: expect.stringContaining('ffmpeg-6.1.zip') + }) + }) + + it('should return null for unsupported platform', () => { + const result = service.getFFmpegVersion('unsupported' as any, 'x64') + expect(result).toBeNull() + }) + }) + + describe('getAllSupportedVersions', () => { + it('should return all supported platform configurations', () => { + const versions = service.getAllSupportedVersions() + + expect(versions).toHaveLength(6) // win32 (x64, arm64), darwin (x64, arm64), linux (x64, arm64) + + // Check that each version has required properties + versions.forEach((version) => { + expect(version).toHaveProperty('version') + expect(version).toHaveProperty('platform') + expect(version).toHaveProperty('arch') + expect(version).toHaveProperty('url') + expect(version).toHaveProperty('size') + }) + + // Check specific platforms exist + const platforms = versions.map((v) => `${v.platform}-${v.arch}`) + expect(platforms).toContain('win32-x64') + expect(platforms).toContain('darwin-arm64') + expect(platforms).toContain('linux-x64') + + // Since we default to China mirror now, verify URLs contain gitcode.com + versions.forEach((version) => { + expect(version.url).toContain('gitcode.com') + }) + }) + + it('should return different versions based on mirror source', () => { + // Test China mirror (default) + service.setMirrorSource(true) + const chinaVersions = service.getAllSupportedVersions() + expect(chinaVersions).toHaveLength(6) + chinaVersions.forEach((version) => { + expect(version.url).toContain('gitcode.com') + }) + + // Test global mirror + service.setMirrorSource(false) + const globalVersions = service.getAllSupportedVersions() + expect(globalVersions).toHaveLength(6) + globalVersions.forEach((version) => { + expect(version.url).not.toContain('gitcode.com') + }) + }) + }) + + describe('removeFFmpeg', () => { + it('should successfully remove existing FFmpeg directory', () => { + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.rmSync).mockReturnValue(undefined) + + const result = service.removeFFmpeg('win32', 'x64') + expect(result).toBe(true) + expect(fs.rmSync).toHaveBeenCalledWith(expect.stringMatching(/6\.1-win32-x64$/), { + recursive: true, + force: true + }) + }) + + it('should return false when FFmpeg directory does not exist', () => { + vi.mocked(fs.existsSync).mockReturnValue(false) + + const result = service.removeFFmpeg('win32', 'x64') + expect(result).toBe(false) + expect(fs.rmSync).not.toHaveBeenCalled() + }) + + it('should handle errors gracefully', () => { + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.rmSync).mockImplementation(() => { + throw new Error('Permission denied') + }) + + const result = service.removeFFmpeg('win32', 'x64') + expect(result).toBe(false) + }) + }) + + describe('cleanupTempFiles', () => { + it('should remove temporary directory if it exists', () => { + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.rmSync).mockReturnValue(undefined) + + service.cleanupTempFiles() + + expect(fs.rmSync).toHaveBeenCalledWith(expect.stringMatching(/[\\/]\.temp$/), { + recursive: true, + force: true + }) + }) + + it('should do nothing if temporary directory does not exist', () => { + vi.mocked(fs.existsSync).mockReturnValue(false) + + service.cleanupTempFiles() + + expect(fs.rmSync).not.toHaveBeenCalled() + }) + }) + + describe('download progress tracking', () => { + it('should track download progress correctly', () => { + const progress = service.getDownloadProgress('win32', 'x64') + expect(progress).toBeNull() // No download in progress + + // Note: Testing actual download would require mocking HTTPS and file operations + // which is complex and better suited for integration tests + }) + + it('should handle download cancellation', () => { + // Start with no download in progress + expect(service.getDownloadProgress('win32', 'x64')).toBeNull() + + // Cancel should not throw even if no download is active + expect(() => service.cancelDownload('win32', 'x64')).not.toThrow() + }) + }) + + describe('error handling', () => { + it('should handle invalid platform gracefully in getFFmpegPath', () => { + expect(() => service.getFFmpegPath('invalid' as any, 'x64')).toThrow() + }) + + it('should return null for invalid platform in getFFmpegVersion', () => { + const result = service.getFFmpegVersion('invalid' as any, 'x64') + expect(result).toBeNull() + }) + + it('should handle filesystem errors in checkFFmpegExists', () => { + vi.mocked(fs.existsSync).mockImplementation(() => { + throw new Error('Filesystem error') + }) + + const result = service.checkFFmpegExists('win32', 'x64') + expect(result).toBe(false) + }) + }) + + describe('IP 地区检测和镜像源选择', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('getIpCountry', () => { + it('should detect China region and return CN', async () => { + const mockResponse = { + json: vi.fn().mockResolvedValue({ + country: 'CN', + city: 'Beijing', + region: 'Beijing' + }) + } + vi.mocked(global.fetch).mockResolvedValue(mockResponse as any) + + // 通过反射访问私有方法进行测试 + const country = await (service as any).getIpCountry() + expect(country).toBe('CN') + expect(global.fetch).toHaveBeenCalledWith('https://ipinfo.io/json', { + signal: expect.any(AbortSignal), + headers: { + 'User-Agent': 'EchoPlayer-FFmpeg-Downloader/2.0', + 'Accept-Language': 'en-US,en;q=0.9' + } + }) + }) + + it('should detect Hong Kong region and return HK', async () => { + const mockResponse = { + json: vi.fn().mockResolvedValue({ + country: 'HK', + city: 'Hong Kong', + region: 'Hong Kong' + }) + } + vi.mocked(global.fetch).mockResolvedValue(mockResponse as any) + + const country = await (service as any).getIpCountry() + expect(country).toBe('HK') + }) + + it('should return CN as default when API fails', async () => { + vi.mocked(global.fetch).mockRejectedValue(new Error('Network error')) + + const country = await (service as any).getIpCountry() + expect(country).toBe('CN') // 默认返回 CN,验证回退逻辑 + }) + + it('should handle timeout properly', async () => { + // Mock fetch that will be aborted due to timeout + vi.mocked(global.fetch).mockImplementation((_, options) => { + return new Promise((resolve, reject) => { + const signal = options?.signal as AbortSignal + if (signal) { + signal.addEventListener('abort', () => { + reject(new Error('The operation was aborted')) + }) + } + // Simulate a long-running request that doesn't resolve in time + setTimeout(() => resolve({} as any), 10000) + }) + }) + + const country = await (service as any).getIpCountry() + expect(country).toBe('CN') // 超时后默认返回 CN + }, 10000) // 增加测试超时时间 + }) + + describe('镜像源选择逻辑', () => { + it('should use China mirror for Chinese regions', () => { + // 手动设置为中国镜像源 + service.setMirrorSource(true) + + const darwinVersion = service.getFFmpegVersion('darwin', 'arm64') + expect(darwinVersion).toMatchObject({ + platform: 'darwin', + arch: 'arm64', + url: 'https://gitcode.com/mkdir700/echoplayer-ffmpeg/releases/download/v0.0.0/darwin-arm64.zip', + extractPath: 'darwin-arm64/ffmpeg' + }) + }) + + it('should use global mirror for non-Chinese regions', () => { + // 手动设置为全球镜像源 + service.setMirrorSource(false) + + const darwinVersion = service.getFFmpegVersion('darwin', 'arm64') + expect(darwinVersion).toMatchObject({ + platform: 'darwin', + arch: 'arm64', + url: 'https://evermeet.cx/ffmpeg/ffmpeg-6.1.zip', + extractPath: 'ffmpeg' + }) + }) + + it('should correctly detect Chinese regions', async () => { + const testCases = [ + { country: 'CN', expected: true }, + { country: 'HK', expected: true }, + { country: 'MO', expected: true }, + { country: 'TW', expected: true }, + { country: 'US', expected: false }, + { country: 'JP', expected: false }, + { country: 'SG', expected: false } + ] + + for (const testCase of testCases) { + const mockResponse = { + json: vi.fn().mockResolvedValue({ country: testCase.country }) + } + vi.mocked(global.fetch).mockResolvedValue(mockResponse as any) + + await (service as any).detectRegionAndSetMirror() + const currentMirror = service.getCurrentMirrorSource() + + expect(currentMirror).toBe(testCase.expected ? 'china' : 'global') + } + }) + }) + + describe('getCurrentMirrorSource', () => { + it('should return current mirror source', () => { + service.setMirrorSource(true) + expect(service.getCurrentMirrorSource()).toBe('china') + + service.setMirrorSource(false) + expect(service.getCurrentMirrorSource()).toBe('global') + }) + }) + + describe('setMirrorSource', () => { + it('should allow manual mirror source override', () => { + // 设置为中国镜像源 + service.setMirrorSource(true) + expect(service.getCurrentMirrorSource()).toBe('china') + + // 切换到全球镜像源 + service.setMirrorSource(false) + expect(service.getCurrentMirrorSource()).toBe('global') + }) + }) + + describe('getAllVersionsByMirror', () => { + it('should return China mirror versions', () => { + const chinaVersions = service.getAllVersionsByMirror('china') + + expect(chinaVersions).toHaveLength(6) + chinaVersions.forEach((version) => { + expect(version.url).toContain('gitcode.com') + expect(version.extractPath).toContain(`${version.platform}-${version.arch}`) + }) + }) + + it('should return global mirror versions', () => { + const globalVersions = service.getAllVersionsByMirror('global') + + expect(globalVersions).toHaveLength(6) + globalVersions.forEach((version) => { + expect(version.url).not.toContain('gitcode.com') + }) + }) + }) + }) + + describe('地区检测集成测试', () => { + it('should set China mirror after successful IP detection', async () => { + const mockResponse = { + json: vi.fn().mockResolvedValue({ country: 'CN' }) + } + vi.mocked(global.fetch).mockResolvedValue(mockResponse as any) + + await (service as any).detectRegionAndSetMirror() + expect(service.getCurrentMirrorSource()).toBe('china') + }) + + it('should set global mirror for non-Chinese regions', async () => { + const mockResponse = { + json: vi.fn().mockResolvedValue({ country: 'US' }) + } + vi.mocked(global.fetch).mockResolvedValue(mockResponse as any) + + await (service as any).detectRegionAndSetMirror() + expect(service.getCurrentMirrorSource()).toBe('global') + }) + + it('should default to China mirror when detection fails', async () => { + vi.mocked(global.fetch).mockRejectedValue(new Error('Network error')) + + await (service as any).detectRegionAndSetMirror() + expect(service.getCurrentMirrorSource()).toBe('china') + }) + }) + + describe('版本配置切换测试', () => { + it('should return different URLs based on mirror source', () => { + // 测试中国镜像源 + service.setMirrorSource(true) + const chinaVersion = service.getFFmpegVersion('darwin', 'arm64') + expect(chinaVersion?.url).toContain('gitcode.com') + expect(chinaVersion?.extractPath).toBe('darwin-arm64/ffmpeg') + + // 测试全球镜像源 + service.setMirrorSource(false) + const globalVersion = service.getFFmpegVersion('darwin', 'arm64') + expect(globalVersion?.url).toContain('evermeet.cx') + expect(globalVersion?.extractPath).toBe('ffmpeg') + }) + + it('should fallback to global mirror when China mirror not supported', () => { + service.setMirrorSource(true) + + // 假设有一个平台在中国镜像源中不支持(这里只是测试逻辑) + // 实际上所有平台都支持,所以这个测试更多是为了测试回退逻辑的代码结构 + const version = service.getFFmpegVersion('darwin', 'arm64') + expect(version).toBeDefined() + expect(version?.platform).toBe('darwin') + expect(version?.arch).toBe('arm64') + }) + }) +}) diff --git a/src/main/services/__tests__/FFmpegService.integration.test.ts b/src/main/services/__tests__/FFmpegService.integration.test.ts new file mode 100644 index 00000000..2b8d5e7d --- /dev/null +++ b/src/main/services/__tests__/FFmpegService.integration.test.ts @@ -0,0 +1,234 @@ +import { app } from 'electron' +import * as fs from 'fs' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import FFmpegService from '../FFmpegService' + +// Mock modules +vi.mock('fs') +vi.mock('path', () => ({ + join: vi.fn((...args) => args.join('/')), + dirname: vi.fn(), + basename: vi.fn() +})) +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(), + getAppPath: vi.fn(), + isPackaged: false + } +})) +vi.mock('../LoggerService', () => ({ + loggerService: { + withContext: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn() + }) + } +})) +vi.mock('child_process') +vi.mock('https') + +describe('FFmpegService Integration Tests', () => { + let ffmpegService: FFmpegService + + beforeEach(() => { + vi.clearAllMocks() + + // Mock app paths + vi.mocked(app.getPath).mockReturnValue('/mock/user/data') + vi.mocked(app.getAppPath).mockReturnValue('/mock/app/path') + + ffmpegService = new FFmpegService() + }) + + describe('FFmpeg path resolution', () => { + it('should prefer bundled FFmpeg when available', () => { + // Mock bundled FFmpeg exists + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.statSync).mockReturnValue({ isFile: () => true } as any) + + const path = ffmpegService.getFFmpegPath() + expect(path).toContain('ffmpeg') + }) + + it('should fall back to system FFmpeg when no bundled version', () => { + // Mock bundled FFmpeg does not exist + vi.mocked(fs.existsSync).mockReturnValue(false) + + const path = ffmpegService.getFFmpegPath() + // System FFmpeg fallback - platform specific executable name + const expectedExecutable = process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg' + expect(path).toBe(expectedExecutable) + }) + }) + + describe('FFmpeg info', () => { + it('should provide comprehensive FFmpeg information', () => { + // Mock bundled FFmpeg exists + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.statSync).mockReturnValue({ isFile: () => true } as any) + + const info = ffmpegService.getFFmpegInfo() + + expect(info).toHaveProperty('path') + expect(info).toHaveProperty('isBundled') + expect(info).toHaveProperty('isDownloaded') + expect(info).toHaveProperty('isSystemFFmpeg') + expect(info).toHaveProperty('platform') + expect(info).toHaveProperty('arch') + expect(info).toHaveProperty('needsDownload') + + expect(info.platform).toBe(process.platform) + expect(info.arch).toBe(process.arch) + }) + + it('should indicate download needed when no bundled FFmpeg', () => { + // Mock no bundled FFmpeg + vi.mocked(fs.existsSync).mockReturnValue(false) + + const info = ffmpegService.getFFmpegInfo() + + expect(info.isBundled).toBe(false) + expect(info.isSystemFFmpeg).toBe(true) + expect(info.needsDownload).toBe(true) + }) + }) + + describe('FFmpeg availability check', () => { + it('should return true for existing bundled FFmpeg', () => { + // Mock bundled FFmpeg exists with proper stats + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.statSync).mockReturnValue({ + isFile: () => true, + mode: 0o755, + size: 1024 * 1024 + } as any) + + const exists = ffmpegService.fastCheckFFmpegExists() + expect(exists).toBe(true) + }) + + it('should return false for non-existent FFmpeg', () => { + // Mock FFmpeg does not exist + vi.mocked(fs.existsSync).mockReturnValue(false) + + const exists = ffmpegService.fastCheckFFmpegExists() + expect(exists).toBe(false) + }) + + it('should return false for directory instead of file', () => { + // Mock path exists but is directory + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.statSync).mockReturnValue({ + isFile: () => false, + mode: 0o755, + size: 0 + } as any) + + const exists = ffmpegService.fastCheckFFmpegExists() + expect(exists).toBe(false) + }) + }) + + describe('Auto-detection functionality', () => { + it('should detect available bundled FFmpeg', async () => { + // Mock bundled FFmpeg exists + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.statSync).mockReturnValue({ + isFile: () => true, + mode: 0o755, + size: 1024 * 1024 + } as any) + + const result = await ffmpegService.autoDetectAndDownload() + + expect(result).toEqual({ + available: true, + needsDownload: false, + downloadTriggered: false + }) + }) + + it('should indicate download needed when no FFmpeg available', async () => { + // Mock no bundled FFmpeg and system check fails + vi.mocked(fs.existsSync).mockReturnValue(false) + vi.spyOn(ffmpegService, 'checkFFmpegExists').mockResolvedValue(false) + + const result = await ffmpegService.autoDetectAndDownload() + + expect(result).toEqual({ + available: false, + needsDownload: true, + downloadTriggered: false + }) + }) + }) + + describe('Service lifecycle', () => { + it('should have download service available', () => { + const downloadService = ffmpegService.getDownloadService() + expect(downloadService).toBeDefined() + expect(typeof downloadService.checkFFmpegExists).toBe('function') + expect(typeof downloadService.downloadFFmpeg).toBe('function') + }) + + it('should cleanup resources on destroy', async () => { + // Should not throw when destroying service + expect(async () => { + await ffmpegService.destroy() + }).not.toThrow() + }) + }) + + describe('Backward compatibility', () => { + it('should maintain existing bundled FFmpeg detection', () => { + // Mock bundled FFmpeg exists + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.statSync).mockReturnValue({ isFile: () => true } as any) + + const isBundled = ffmpegService.isUsingBundledFFmpeg() + expect(isBundled).toBe(true) + }) + + it('should not break existing functionality', async () => { + // Mock bundled FFmpeg exists + vi.mocked(fs.existsSync).mockReturnValue(true) + vi.mocked(fs.statSync).mockReturnValue({ + isFile: () => true, + mode: 0o755, + size: 1024 * 1024 + } as any) + + // These methods should work without throwing + const path = ffmpegService.getFFmpegPath() + const info = ffmpegService.getFFmpegInfo() + const exists = ffmpegService.fastCheckFFmpegExists() + + expect(path).toBeTruthy() + expect(info).toBeTruthy() + expect(exists).toBe(true) + }) + }) + + describe('Error handling', () => { + it('should handle filesystem errors gracefully', () => { + vi.mocked(fs.existsSync).mockImplementation(() => { + throw new Error('Filesystem error') + }) + + expect(() => { + const exists = ffmpegService.fastCheckFFmpegExists() + expect(exists).toBe(false) + }).not.toThrow() + }) + + it('should handle missing download service gracefully', () => { + expect(() => { + const downloadService = ffmpegService.getDownloadService() + expect(downloadService).toBeDefined() + }).not.toThrow() + }) + }) +}) diff --git a/src/preload/index.ts b/src/preload/index.ts index d8b2cc20..5d642519 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -182,7 +182,40 @@ const api = { getPath: (): Promise => ipcRenderer.invoke(IpcChannel.Ffmpeg_GetPath), warmup: (): Promise => ipcRenderer.invoke(IpcChannel.Ffmpeg_Warmup), getWarmupStatus: (): Promise<{ isWarmedUp: boolean; isWarming: boolean }> => - ipcRenderer.invoke(IpcChannel.Ffmpeg_GetWarmupStatus) + ipcRenderer.invoke(IpcChannel.Ffmpeg_GetWarmupStatus), + getInfo: (): Promise<{ + path: string + isBundled: boolean + isDownloaded: boolean + isSystemFFmpeg: boolean + platform: string + arch: string + version?: string + needsDownload: boolean + }> => ipcRenderer.invoke(IpcChannel.Ffmpeg_GetInfo), + autoDetectAndDownload: (): Promise<{ + available: boolean + needsDownload: boolean + downloadTriggered: boolean + }> => ipcRenderer.invoke(IpcChannel.Ffmpeg_AutoDetectAndDownload), + // FFmpeg 下载管理 + download: { + checkExists: (platform?: string, arch?: string): Promise => + ipcRenderer.invoke(IpcChannel.FfmpegDownload_CheckExists, platform, arch), + getVersion: (platform?: string, arch?: string): Promise => + ipcRenderer.invoke(IpcChannel.FfmpegDownload_GetVersion, platform, arch), + download: (platform?: string, arch?: string): Promise => + ipcRenderer.invoke(IpcChannel.FfmpegDownload_Download, platform, arch), + getProgress: (platform?: string, arch?: string): Promise => + ipcRenderer.invoke(IpcChannel.FfmpegDownload_GetProgress, platform, arch), + cancel: (platform?: string, arch?: string): Promise => + ipcRenderer.invoke(IpcChannel.FfmpegDownload_Cancel, platform, arch), + remove: (platform?: string, arch?: string): Promise => + ipcRenderer.invoke(IpcChannel.FfmpegDownload_Remove, platform, arch), + getAllVersions: (): Promise => + ipcRenderer.invoke(IpcChannel.FfmpegDownload_GetAllVersions), + cleanupTemp: (): Promise => ipcRenderer.invoke(IpcChannel.FfmpegDownload_CleanupTemp) + } }, mediainfo: { checkExists: (): Promise => ipcRenderer.invoke(IpcChannel.MediaInfo_CheckExists), diff --git a/src/renderer/src/assets/styles/ant.scss b/src/renderer/src/assets/styles/ant.scss index fce0f220..fd8af63f 100644 --- a/src/renderer/src/assets/styles/ant.scss +++ b/src/renderer/src/assets/styles/ant.scss @@ -292,6 +292,10 @@ } } +.ant-btn { + box-shadow: none; +} + /* Confirm Modal buttons - dark mode friendly */ .ant-modal.ant-modal-confirm { .ant-modal-confirm-btns { diff --git a/src/renderer/src/components/FFmpegDownloadPrompt.tsx b/src/renderer/src/components/FFmpegDownloadPrompt.tsx new file mode 100644 index 00000000..342debd4 --- /dev/null +++ b/src/renderer/src/components/FFmpegDownloadPrompt.tsx @@ -0,0 +1,286 @@ +import { + BORDER_RADIUS, + FONT_SIZES, + FONT_WEIGHTS, + SPACING +} from '@renderer/infrastructure/styles/theme' +import { Modal } from 'antd' +import { Film, Gauge, Shield, Zap } from 'lucide-react' +import { FC } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate } from 'react-router-dom' +import styled from 'styled-components' + +interface FFmpegDownloadPromptProps { + open: boolean + onClose: () => void +} + +/** + * FFmpeg下载引导对话框 + * 当视频解析失败且缺少FFmpeg时显示,引导用户下载FFmpeg + */ +export const FFmpegDownloadPrompt: FC = ({ open, onClose }) => { + const { t } = useTranslation() + const navigate = useNavigate() + + const handleDownload = () => { + onClose() + // 跳转到设置页面并传递自动下载参数 + navigate('/settings/plugins?autoDownload=true') + } + + const handleLater = () => { + onClose() + } + + return ( + + + + + + + + {t('settings.plugins.ffmpeg.prompt.title')} + {t('settings.plugins.ffmpeg.prompt.subtitle')} + + + + + {t('settings.plugins.ffmpeg.prompt.benefits.title')} + + + + + + + + {t('settings.plugins.ffmpeg.prompt.benefits.compatibility.title')} + + + {t('settings.plugins.ffmpeg.prompt.benefits.compatibility.description')} + + + + + + + + + + + {t('settings.plugins.ffmpeg.prompt.benefits.performance.title')} + + + {t('settings.plugins.ffmpeg.prompt.benefits.performance.description')} + + + + + + + + + + + {t('settings.plugins.ffmpeg.prompt.benefits.reliability.title')} + + + {t('settings.plugins.ffmpeg.prompt.benefits.reliability.description')} + + + + + + + + {t('settings.plugins.ffmpeg.prompt.effort.title')} + + {t('settings.plugins.ffmpeg.prompt.effort.description')} + + + + + + {t('settings.plugins.ffmpeg.prompt.actions.later')} + + + {t('settings.plugins.ffmpeg.prompt.actions.download')} + + + + + ) +} + +const PromptContainer = styled.div` + padding: ${SPACING.LG}px; +` + +const HeaderSection = styled.div` + display: flex; + align-items: flex-start; + gap: ${SPACING.MD}px; + margin-bottom: ${SPACING.LG}px; +` + +const IconWrapper = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + background: linear-gradient(135deg, var(--ant-color-primary), var(--ant-color-info)); + border-radius: ${BORDER_RADIUS.LG}px; + color: white; + flex-shrink: 0; +` + +const HeaderContent = styled.div` + flex: 1; +` + +const Title = styled.h2` + font-size: ${FONT_SIZES.XL}px; + font-weight: ${FONT_WEIGHTS.BOLD}; + color: var(--ant-color-text); + margin: 0 0 ${SPACING.XXS}px 0; + line-height: 1.3; +` + +const Subtitle = styled.p` + font-size: ${FONT_SIZES.SM}px; + color: var(--ant-color-text-secondary); + margin: 0; + line-height: 1.5; +` + +const BenefitsSection = styled.div` + margin-bottom: ${SPACING.LG}px; +` + +const SectionTitle = styled.h3` + font-size: ${FONT_SIZES.LG}px; + font-weight: ${FONT_WEIGHTS.SEMIBOLD}; + color: var(--ant-color-text); + margin: 0 0 ${SPACING.MD}px 0; +` + +const BenefitsList = styled.div` + display: flex; + flex-direction: column; + gap: ${SPACING.MD}px; +` + +const BenefitItem = styled.div` + display: flex; + align-items: flex-start; + gap: ${SPACING.SM}px; +` + +const BenefitIcon = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: var(--ant-color-primary-bg); + border-radius: ${BORDER_RADIUS.BASE}px; + color: var(--ant-color-primary); + flex-shrink: 0; + margin-top: 2px; +` + +const BenefitContent = styled.div` + flex: 1; +` + +const BenefitTitle = styled.h4` + font-size: ${FONT_SIZES.SM}px; + font-weight: ${FONT_WEIGHTS.SEMIBOLD}; + color: var(--ant-color-text); + margin: 0 0 ${SPACING.XXS}px 0; +` + +const BenefitDescription = styled.p` + font-size: ${FONT_SIZES.XS}px; + color: var(--ant-color-text-secondary); + margin: 0; + line-height: 1.5; +` + +const EffortSection = styled.div` + padding: ${SPACING.MD}px; + background: var(--ant-color-fill-quaternary); + border-radius: ${BORDER_RADIUS.BASE}px; + margin-bottom: ${SPACING.LG}px; +` + +const EffortTitle = styled.h4` + font-size: ${FONT_SIZES.SM}px; + font-weight: ${FONT_WEIGHTS.SEMIBOLD}; + color: var(--ant-color-text); + margin: 0 0 ${SPACING.XXS}px 0; +` + +const EffortDescription = styled.p` + font-size: ${FONT_SIZES.XS}px; + color: var(--ant-color-text-secondary); + margin: 0; + line-height: 1.5; +` + +const ActionSection = styled.div` + display: flex; + justify-content: flex-end; + gap: ${SPACING.SM}px; +` + +const SecondaryButton = styled.button` + padding: ${SPACING.XS}px ${SPACING.MD}px; + background: transparent; + border: 1px solid var(--ant-color-border); + border-radius: ${BORDER_RADIUS.SM}px; + color: var(--ant-color-text-secondary); + font-size: ${FONT_SIZES.SM}px; + font-weight: ${FONT_WEIGHTS.MEDIUM}; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + border-color: var(--ant-color-primary); + color: var(--ant-color-primary); + } +` + +const PrimaryButton = styled.button` + display: flex; + align-items: center; + gap: ${SPACING.XS}px; + padding: ${SPACING.XS}px ${SPACING.MD}px; + background: var(--ant-color-primary); + border: 1px solid var(--ant-color-primary); + border-radius: ${BORDER_RADIUS.SM}px; + color: white; + font-size: ${FONT_SIZES.SM}px; + font-weight: ${FONT_WEIGHTS.SEMIBOLD}; + cursor: pointer; + transition: all 0.2s ease; + + &:hover { + background: var(--ant-color-primary-hover); + border-color: var(--ant-color-primary-hover); + transform: translateY(-1px); + } +` + +export default FFmpegDownloadPrompt diff --git a/src/renderer/src/components/IndicatorLight.tsx b/src/renderer/src/components/IndicatorLight.tsx index e57ee503..c6361b6e 100644 --- a/src/renderer/src/components/IndicatorLight.tsx +++ b/src/renderer/src/components/IndicatorLight.tsx @@ -1,5 +1,5 @@ import React from 'react' -import styled, { keyframes } from 'styled-components' +import styled, { css, keyframes } from 'styled-components' interface IndicatorLightProps { /** @@ -57,9 +57,9 @@ const IndicatorLightContainer = styled.div<{ ${(props) => props.$pulsing && - ` - animation: ${pulse} 2s ease-in-out infinite; - `} + css` + animation: ${pulse} 2s ease-in-out infinite; + `} ` const colorMap = { diff --git a/src/renderer/src/components/app/Navbar.tsx b/src/renderer/src/components/app/Navbar.tsx index 45e59cc2..dd9de916 100644 --- a/src/renderer/src/components/app/Navbar.tsx +++ b/src/renderer/src/components/app/Navbar.tsx @@ -112,8 +112,7 @@ const NavbarMainContainer = styled.div<{ $isFullscreen: boolean }>` padding: 0 ${isMac ? '20px' : 0}; font-weight: bold; color: var(--color-text-1); - padding-right: ${({ $isFullscreen }) => - $isFullscreen ? '12px' : isWin ? '140px' : isLinux ? '120px' : '12px'}; + padding-right: 12px; ` const NavbarHeaderContent = styled.div` diff --git a/src/renderer/src/hooks/useVideoFileSelect.ts b/src/renderer/src/hooks/useVideoFileSelect.ts index 1836d951..a26e517d 100644 --- a/src/renderer/src/hooks/useVideoFileSelect.ts +++ b/src/renderer/src/hooks/useVideoFileSelect.ts @@ -17,6 +17,8 @@ interface UseVideoFileSelectOptions { export interface UseVideoFileSelectReturn { selectVideoFile: () => Promise isProcessing: boolean + showFFmpegPrompt: boolean + setShowFFmpegPrompt: (show: boolean) => void } /** @@ -40,6 +42,7 @@ export function useVideoFileSelect( ): UseVideoFileSelectReturn { const { onSuccess } = options const [isProcessing, setIsProcessing] = useState(false) + const [showFFmpegPrompt, setShowFFmpegPrompt] = useState(false) const processVideoFile = useCallback( async (file: FileMetadata) => { @@ -109,6 +112,30 @@ export function useVideoFileSelect( }) if (!videoInfo) { + // 检查是否是 FFmpeg 相关问题 + try { + const ffmpegInfo = await window.api.ffmpeg.getInfo() + if (ffmpegInfo.needsDownload) { + // 显示FFmpeg引导对话框而不是直接抛出错误 + setShowFFmpegPrompt(true) + return + } else if (ffmpegInfo.isSystemFFmpeg) { + throw new Error( + '视频处理失败。可能是系统 FFmpeg 版本不兼容或视频文件损坏。\n\n建议在设置中下载官方视频处理组件以获得更好的兼容性。' + ) + } + } catch (ffmpegError) { + // 如果 FFmpeg 检测本身失败,检查是否是需要下载的情况 + if ( + (ffmpegError as Error).message.includes('视频处理组件') || + (ffmpegError as Error).message.includes('needsDownload') + ) { + setShowFFmpegPrompt(true) + return + } + } + + // 如果不是 FFmpeg 问题,使用通用错误消息 throw new Error('无法获取视频信息,请检查文件是否为有效的视频文件') } @@ -196,6 +223,8 @@ export function useVideoFileSelect( return { selectVideoFile, - isProcessing + isProcessing, + showFFmpegPrompt, + setShowFFmpegPrompt } } diff --git a/src/renderer/src/i18n/label.ts b/src/renderer/src/i18n/label.ts index 917e7ce1..5f4224f5 100644 --- a/src/renderer/src/i18n/label.ts +++ b/src/renderer/src/i18n/label.ts @@ -176,10 +176,6 @@ const shortcutKeys = [ 'toggle_subtitle_panel', 'playback_rate_next', 'playback_rate_prev', - 'subtitle_mode_none', - 'subtitle_mode_original', - 'subtitle_mode_translated', - 'subtitle_mode_bilingual', 'copy_subtitle' ] as const diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 5d4ec8ab..6e869491 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -103,6 +103,88 @@ }, "title": "Playback Settings" }, + "plugins": { + "ffmpeg": { + "actions": { + "label": "Actions", + "refresh": "Refresh Status", + "warmup": "Test" + }, + "current_path": "Current Path", + "description": "FFmpeg is the essential core component for processing video files, used for extracting video information and performing video processing tasks.", + "download": { + "button": "Download FFmpeg", + "cancel": "Cancel Download", + "cancelled": "Download cancelled", + "downloading": "Downloading", + "failed": "Download failed, please check your network connection and retry", + "progress": "Download Progress", + "size": "Download Size", + "success": "Download completed", + "warming_up": "Warming up...", + "warmup_failed": "Warmup failed, please check installation", + "warmup_success": "Warmup successful, FFmpeg is now available" + }, + "prompt": { + "title": "Video Processing Component Required", + "subtitle": "EchoPlayer needs FFmpeg to process this video file", + "benefits": { + "title": "Benefits of installing FFmpeg:", + "compatibility": { + "title": "Broader Format Support", + "description": "Support for almost all video formats including MP4, AVI, MKV, MOV, WMV and more" + }, + "performance": { + "title": "Faster Processing Speed", + "description": "Optimized decoding algorithms for smoother playback experience" + }, + "reliability": { + "title": "Higher Stability", + "description": "Professional-grade video processing capabilities, reducing parsing failures and playback errors" + } + }, + "effort": { + "title": "Easy and Quick Installation", + "description": "One-click automatic download, about 50MB, installation completes in 2-3 minutes. No manual configuration needed, ready to use immediately." + }, + "actions": { + "download": "Download FFmpeg Now", + "later": "Handle Later" + } + }, + "path": { + "browse": "Browse", + "browse_title": "Select FFmpeg Executable", + "invalid": "Invalid path or file does not exist", + "label": "Path", + "placeholder": "FFmpeg path will be auto-filled after download, or specify manually", + "valid": "Path validation successful", + "validation_failed": "Path validation failed" + }, + "status": { + "available": "Available", + "custom_path": "Custom Path", + "downloading": "Downloading", + "installed": "Installed", + "label": "Status", + "loading": "Detecting...", + "not_installed": "Not Installed", + "system_version": "System Version", + "unknown": "Status Unknown" + }, + "title": "Video Processing Component (FFmpeg)", + "uninstall": { + "button": "Uninstall", + "confirm": "Confirm Uninstall", + "confirm_description": "This will remove the downloaded FFmpeg files, but will not affect system-installed versions.", + "confirm_title": "Confirm FFmpeg Uninstall?", + "failed": "Uninstall failed, please try again", + "success": "FFmpeg uninstalled successfully" + }, + "version": "Version" + }, + "title": "Plugin Management" + }, "shortcuts": { "action": "operation", "actions": "operation", @@ -117,6 +199,8 @@ "new_topic": "Create a new topic", "next_subtitle": "next subtitle", "play_pause": "Play/Pause", + "playback_rate_next": "Next favorite rate", + "playback_rate_prev": "Previous favorite rate", "press_shortcut": "Press the shortcut key", "previous_subtitle": "Previous subtitle", "reset_defaults": "Reset default shortcuts", @@ -131,8 +215,6 @@ "show_app": "Show / Hide App", "show_settings": "Open settings", "single_loop": "Loop playback", - "playback_rate_next": "Next favorite rate", - "playback_rate_prev": "Previous favorite rate", "title": "Shortcut keys", "toggle_fullscreen": "Switch to fullscreen", "toggle_new_context": "Clear context", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 42a7197b..23f2fc0f 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -4,6 +4,8 @@ }, "common": { "cancel": "取消", + "disabled": "已关闭", + "enabled": "已开启", "favorites": "收藏", "favorites_developing": "该功能正在开发中", "grid_view": "矩阵视图", @@ -15,21 +17,22 @@ "search": "搜索", "search_no_results": "暂无搜索结果", "search_placeholder": "搜索视频...", - "enabled": "已开启", - "disabled": "已关闭", "selectedItems": "已选择 {{count}} 项" }, "docs": { "title": "帮助文档" }, - "search": { - "searching": "搜索中...", - "found_videos": "找到 {{count}} 个视频", - "no_videos_found": "未找到相关视频", - "search_videos": "搜索视频" - }, "home": { "add_video": "新增视频", + "delete": { + "button_cancel": "取消", + "button_ok": "删除", + "confirm_content": "确定要删除视频 \"{{title}}\" 的观看记录吗?", + "confirm_title": "确认删除", + "confirm_warning": "此操作将删除该视频的播放历史和进度信息", + "error_message": "删除失败,请重试", + "success_message": "视频记录删除成功" + }, "no_video": "空空如也", "no_video_desc": "支持 MP4、AVI、MKV、MOV 等常见视频格式", "processing": "处理中", @@ -38,29 +41,55 @@ "viewMode": { "grid": "矩阵", "list": "列表" - }, - "delete": { - "confirm_title": "确认删除", - "confirm_content": "确定要删除视频 \"{{title}}\" 的观看记录吗?", - "confirm_warning": "此操作将删除该视频的播放历史和进度信息", - "button_ok": "删除", - "button_cancel": "取消", - "success_message": "视频记录删除成功", - "error_message": "删除失败,请重试" } }, "player": { "controls": { "auto_pause": { - "subtitle_end": "在单个字幕结束时暂停", "disabled": "字幕未加载", "enabled": "自动暂停", "resume_delay": "恢复延迟(秒)", - "resume_title": "自动恢复播放" + "resume_title": "自动恢复播放", + "subtitle_end": "在单个字幕结束时暂停" + }, + "copy": { + "failed": "复制失败,无法访问剪贴板", + "success": "已复制" + }, + "fullscreen": { + "enter": "全屏", + "exit": "退出全屏" + }, + "loop": { + "count": "循环次数", + "disabled": "字幕未加载", + "enabled": "循环", + "mode": { + "single": "单句循环" + }, + "title": "循环模式" }, "subtitle": { + "background-type": { + "blur": { + "tooltip": "模糊背景" + }, + "solid-black": { + "tooltip": "黑色背景" + }, + "solid-gray": { + "tooltip": "灰色背景" + }, + "title": "背景样式", + "transparent": { + "tooltip": "透明背景" + } + }, "display-mode": { - "title": "显示模式", + "bilingual": { + "label": "双语", + "tooltip": "显示双语字幕 (Ctrl+4)" + }, "hide": { "label": "隐藏", "tooltip": "隐藏字幕 (Ctrl+1)" @@ -69,113 +98,84 @@ "label": "原文", "tooltip": "仅显示原文字幕 (Ctrl+2)" }, + "title": "显示模式", "translation": { "label": "译文", "tooltip": "仅显示译文字幕 (Ctrl+3)" - }, - "bilingual": { - "label": "双语", - "tooltip": "显示双语字幕 (Ctrl+4)" - } - }, - "background-type": { - "title": "背景样式", - "transparent": { - "tooltip": "透明背景" - }, - "blur": { - "tooltip": "模糊背景" - }, - "solid-black": { - "tooltip": "黑色背景" - }, - "solid-gray": { - "tooltip": "灰色背景" } } - }, - "loop": { - "count": "循环次数", - "mode": { - "single": "单句循环" - }, - "title": "循环模式", - "disabled": "字幕未加载", - "enabled": "循环" - }, - "fullscreen": { - "enter": "全屏", - "exit": "退出全屏" - }, - "copy": { - "success": "已复制", - "failed": "复制失败,无法访问剪贴板" } }, - "subtitles": { - "hide": "隐藏字幕列表", - "show": "展开字幕列表" - }, "errorRecovery": { - "errors": { - "fileMissing": { - "title": "视频文件缺失", - "description": "原视频文件可能已被删除、移动或重命名" - }, - "unsupportedFormat": { - "title": "不支持的视频格式", - "description": "当前视频格式不受支持或文件已损坏" - }, - "decodeError": { - "title": "视频解码错误", - "description": "视频文件可能损坏或编码格式不兼容" - }, - "networkError": { - "title": "网络错误", - "description": "加载网络视频时发生连接错误" - }, - "unknown": { - "title": "播放错误", - "description": "视频播放时发生未知错误" - } - }, "actions": { - "relocateFile": "重新选择文件", "backToHome": "返回首页", + "relocateFile": "重新选择文件", "removeFromLibrary": "从媒体库移除" }, "dialogs": { "relocate": { - "title": "重新选择文件", "confirmText": "我已了解,继续选择", "content": { - "warning": "请务必选择与当前视频记录对应的原始文件。", - "note": "⚠️ 选择错误的文件可能导致播放进度、字幕等数据不匹配。" - } + "note": "⚠️ 选择错误的文件可能导致播放进度、字幕等数据不匹配。", + "warning": "请务必选择与当前视频记录对应的原始文件。" + }, + "title": "重新选择文件" }, "remove": { - "title": "确认从媒体库移除?", "confirmText": "确认移除", "content": { "description": "此操作将从媒体库中永久删除该视频记录,包括:", "items": { + "personalSettings": "个人设置和标记", "playbackHistory": "播放进度和历史记录", - "subtitleLinks": "已导入的字幕文件关联", - "personalSettings": "个人设置和标记" + "subtitleLinks": "已导入的字幕文件关联" }, "warning": "⚠️ 此操作不可撤销,但不会删除原视频文件。" - } + }, + "title": "确认从媒体库移除?" + } + }, + "errors": { + "decodeError": { + "description": "视频文件可能损坏或编码格式不兼容", + "title": "视频解码错误" + }, + "fileMissing": { + "description": "原视频文件可能已被删除、移动或重命名", + "title": "视频文件缺失" + }, + "networkError": { + "description": "加载网络视频时发生连接错误", + "title": "网络错误" + }, + "unknown": { + "description": "视频播放时发生未知错误", + "title": "播放错误" + }, + "unsupportedFormat": { + "description": "当前视频格式不受支持或文件已损坏", + "title": "不支持的视频格式" } }, "fileDialog": { - "videoFiles": "视频文件", - "allFiles": "所有文件" + "allFiles": "所有文件", + "videoFiles": "视频文件" }, "pathInfo": { "label": "文件路径" } + }, + "subtitles": { + "hide": "隐藏字幕列表", + "show": "展开字幕列表" } }, + "search": { + "found_videos": "找到 {{count}} 个视频", + "no_videos_found": "未找到相关视频", + "search_videos": "搜索视频", + "searching": "搜索中..." + }, "settings": { "about": { "checkUpdate": { @@ -285,6 +285,88 @@ }, "title": "播放设置" }, + "plugins": { + "ffmpeg": { + "actions": { + "label": "操作", + "refresh": "刷新状态", + "warmup": "测试" + }, + "current_path": "当前路径", + "description": "FFmpeg 是处理视频文件所必需的核心组件,用于获取视频信息和执行视频处理任务。", + "download": { + "button": "下载 FFmpeg", + "cancel": "取消下载", + "cancelled": "下载已取消", + "downloading": "下载中", + "failed": "下载失败,请检查网络连接后重试", + "progress": "下载进度", + "size": "下载大小", + "success": "下载完成", + "warming_up": "正在预热...", + "warmup_failed": "预热失败,请检查安装", + "warmup_success": "预热成功,FFmpeg 已可用" + }, + "path": { + "browse": "浏览", + "browse_title": "选择 FFmpeg 可执行文件", + "invalid": "路径无效或文件不存在", + "label": "路径", + "placeholder": "FFmpeg 路径将在下载后自动填入,也可手动指定", + "valid": "路径验证成功", + "validation_failed": "路径验证失败" + }, + "prompt": { + "actions": { + "download": "立即下载 FFmpeg", + "later": "稍后处理" + }, + "benefits": { + "compatibility": { + "description": "支持 MP4、AVI、MKV、MOV、WMV 等几乎所有视频格式", + "title": "更广泛的格式支持" + }, + "performance": { + "description": "优化的解码算法,提供更流畅的播放体验", + "title": "更快的处理速度" + }, + "reliability": { + "description": "专业级的视频处理能力,减少解析失败和播放错误", + "title": "更高的稳定性" + }, + "title": "安装 FFmpeg 的好处:" + }, + "effort": { + "description": "一键自动下载,约 50MB 大小。无需手动配置,立即可用。", + "title": "安装轻松简单" + }, + "subtitle": "EchoPlayer 需要 FFmpeg 来处理这个视频文件", + "title": "需要视频处理组件" + }, + "status": { + "available": "可用", + "custom_path": "自定义路径", + "downloading": "下载中", + "installed": "已安装", + "label": "状态", + "loading": "检测中...", + "not_installed": "未安装", + "system_version": "系统版本", + "unknown": "状态未知" + }, + "title": "视频处理组件 (FFmpeg)", + "uninstall": { + "button": "卸载", + "confirm": "确认卸载", + "confirm_description": "此操作将删除已下载的 FFmpeg 文件,但不会影响系统安装的版本。", + "confirm_title": "确认卸载 FFmpeg?", + "failed": "卸载失败,请重试", + "success": "FFmpeg 卸载成功" + }, + "version": "版本" + }, + "title": "插件管理" + }, "shortcut": { "title": "快捷键设置" }, @@ -303,6 +385,8 @@ "new_topic": "新建话题", "next_subtitle": "下一字幕", "play_pause": "播放/暂停", + "playback_rate_next": "下一个常用速度", + "playback_rate_prev": "上一个常用速度", "press_shortcut": "按下快捷键", "previous_subtitle": "上一字幕", "replay_current_subtitle": "重播当前字幕", @@ -318,8 +402,6 @@ "show_app": "显示 / 隐藏应用", "show_settings": "打开设置", "single_loop": "循环播放", - "playback_rate_next": "下一个常用速度", - "playback_rate_prev": "上一个常用速度", "title": "快捷键", "toggle_fullscreen": "切换全屏", "toggle_new_context": "清除上下文", diff --git a/src/renderer/src/infrastructure/constants/shortcuts.const.ts b/src/renderer/src/infrastructure/constants/shortcuts.const.ts index a0024f38..15544459 100644 --- a/src/renderer/src/infrastructure/constants/shortcuts.const.ts +++ b/src/renderer/src/infrastructure/constants/shortcuts.const.ts @@ -82,34 +82,6 @@ export const DEFAULT_SHORTCUTS: Shortcut[] = [ enabled: true, system: true }, - { - key: 'subtitle_mode_none', - shortcut: ['CommandOrControl', '1'], - editable: true, - enabled: true, - system: false - }, - { - key: 'subtitle_mode_original', - shortcut: ['CommandOrControl', '2'], - editable: true, - enabled: true, - system: false - }, - { - key: 'subtitle_mode_translated', - shortcut: ['CommandOrControl', '3'], - editable: true, - enabled: true, - system: false - }, - { - key: 'subtitle_mode_bilingual', - shortcut: ['CommandOrControl', '4'], - editable: true, - enabled: true, - system: false - }, { key: 'toggle_subtitle_panel', shortcut: ['CommandOrControl', 'BracketRight'], diff --git a/src/renderer/src/pages/home/EmptyState.tsx b/src/renderer/src/pages/home/EmptyState.tsx index 04e0a600..433aa64b 100644 --- a/src/renderer/src/pages/home/EmptyState.tsx +++ b/src/renderer/src/pages/home/EmptyState.tsx @@ -9,13 +9,30 @@ const { Title: AntTitle, Paragraph } = Typography interface EmptyStateProps { onVideoAdded?: () => void + onShowFFmpegPrompt?: (show: boolean) => void } -export function EmptyState({ onVideoAdded }: EmptyStateProps): React.JSX.Element { +export function EmptyState({ + onVideoAdded, + onShowFFmpegPrompt +}: EmptyStateProps): React.JSX.Element { const { t } = useTranslation() - const { selectVideoFile, isProcessing } = useVideoFileSelect({ - onSuccess: onVideoAdded - }) + const { selectVideoFile, isProcessing, showFFmpegPrompt, setShowFFmpegPrompt } = + useVideoFileSelect({ + onSuccess: onVideoAdded + }) + + // 将showFFmpegPrompt状态传递给父组件 + React.useEffect(() => { + onShowFFmpegPrompt?.(showFFmpegPrompt) + }, [showFFmpegPrompt, onShowFFmpegPrompt]) + + // 当关闭FFmpeg提示时,重置状态 + React.useEffect(() => { + if (!showFFmpegPrompt) { + setShowFFmpegPrompt(false) + } + }, [showFFmpegPrompt, setShowFFmpegPrompt]) return ( diff --git a/src/renderer/src/pages/home/HeaderNavbar.tsx b/src/renderer/src/pages/home/HeaderNavbar.tsx index 2665b925..a3bd6522 100644 --- a/src/renderer/src/pages/home/HeaderNavbar.tsx +++ b/src/renderer/src/pages/home/HeaderNavbar.tsx @@ -11,9 +11,14 @@ import VideoAddButton from './VideoAddButton' interface Props { videoListViewMode: 'grid' | 'list' setVideoListViewMode: (mode: 'grid' | 'list') => void + onShowFFmpegPrompt?: (show: boolean) => void } -const HeaderNavbar: FC = ({ videoListViewMode, setVideoListViewMode }) => { +const HeaderNavbar: FC = ({ + videoListViewMode, + setVideoListViewMode, + onShowFFmpegPrompt +}) => { const { t } = useTranslation() const { showSearch } = useSearchStore() @@ -28,7 +33,7 @@ const HeaderNavbar: FC = ({ videoListViewMode, setVideoListViewMode }) => return ( - + diff --git a/src/renderer/src/pages/home/HomePage.tsx b/src/renderer/src/pages/home/HomePage.tsx index e03cb626..620ef3ed 100644 --- a/src/renderer/src/pages/home/HomePage.tsx +++ b/src/renderer/src/pages/home/HomePage.tsx @@ -1,4 +1,5 @@ import { loggerService } from '@logger' +import FFmpegDownloadPrompt from '@renderer/components/FFmpegDownloadPrompt' import HomePageVideoService, { type HomePageVideoItem } from '@renderer/services/HomePageVideos' import { VideoLibraryService } from '@renderer/services/VideoLibrary' import { useSettingsStore } from '@renderer/state/stores/settings.store' @@ -83,6 +84,7 @@ export function HomePage(): React.JSX.Element { } = useVideoListStore() const [videos, setVideos] = React.useState([]) + const [showFFmpegPrompt, setShowFFmpegPrompt] = React.useState(false) const navigate = useNavigate() // 初始化时使用缓存数据 @@ -130,6 +132,14 @@ export function HomePage(): React.JSX.Element { loadVideos() }, [loadVideos]) + const handleShowFFmpegPrompt = React.useCallback((show: boolean) => { + setShowFFmpegPrompt(show) + }, []) + + const handleCloseFFmpegPrompt = React.useCallback(() => { + setShowFFmpegPrompt(false) + }, []) + // 删除视频记录 const handleDeleteVideo = React.useCallback( async (video: HomePageVideoItem) => { @@ -178,13 +188,17 @@ export function HomePage(): React.JSX.Element { {isLoading && !isInitialized ? ( ) : videos.length === 0 ? ( - + ) : ( + + {/* FFmpeg下载引导对话框 */} + ) } diff --git a/src/renderer/src/pages/home/VideoAddButton.tsx b/src/renderer/src/pages/home/VideoAddButton.tsx index 31aa0a1b..0d0a6bfe 100644 --- a/src/renderer/src/pages/home/VideoAddButton.tsx +++ b/src/renderer/src/pages/home/VideoAddButton.tsx @@ -2,18 +2,34 @@ import { useVideoFileSelect } from '@renderer/hooks/useVideoFileSelect' import { useVideoListStore } from '@renderer/state/stores/video-list.store' import { Tooltip } from 'antd' import { FilePlus } from 'lucide-react' -import { FC } from 'react' +import { FC, useEffect } from 'react' import { useTranslation } from 'react-i18next' import { NavbarIcon } from '.' -const VideoAddButton: FC = () => { +interface VideoAddButtonProps { + onShowFFmpegPrompt?: (show: boolean) => void +} + +const VideoAddButton: FC = ({ onShowFFmpegPrompt }) => { const { t } = useTranslation() const { refreshVideoList } = useVideoListStore() - const { selectVideoFile } = useVideoFileSelect({ + const { selectVideoFile, showFFmpegPrompt, setShowFFmpegPrompt } = useVideoFileSelect({ onSuccess: refreshVideoList }) + // 将showFFmpegPrompt状态传递给父组件 + useEffect(() => { + onShowFFmpegPrompt?.(showFFmpegPrompt) + }, [showFFmpegPrompt, onShowFFmpegPrompt]) + + // 当关闭FFmpeg提示时,重置状态 + useEffect(() => { + if (!showFFmpegPrompt) { + setShowFFmpegPrompt(false) + } + }, [showFFmpegPrompt, setShowFFmpegPrompt]) + return ( diff --git a/src/renderer/src/pages/player/PlayerPage.tsx b/src/renderer/src/pages/player/PlayerPage.tsx index 933d5f06..9283064c 100644 --- a/src/renderer/src/pages/player/PlayerPage.tsx +++ b/src/renderer/src/pages/player/PlayerPage.tsx @@ -197,9 +197,20 @@ function PlayerPage() { try { logger.info('开始重新定位视频文件', { videoId, newPath }) - // 更新数据库中的文件路径 - // 这里需要调用数据库服务来更新文件记录 - // 暂时先更新本地状态,实际实现需要更新数据库 + // 1. 获取视频记录以获得文件ID + const videoLibService = new VideoLibraryService() + const record = await videoLibService.getRecordById(videoId) + if (!record) { + throw new Error('视频记录不存在') + } + + // 2. 更新数据库中的文件路径 + const updatedFile = await db.files.updateFile(record.fileId, { path: newPath }) + if (!updatedFile) { + throw new Error('更新文件路径失败') + } + + // 3. 更新本地状态 const newFileUrl = toFileUrl(newPath) const updatedVideoData = { ...videoData, @@ -209,9 +220,16 @@ function PlayerPage() { setVideoData(updatedVideoData) setVideoError(null) // 清除错误状态 - logger.info('视频文件路径已更新', { videoId, newPath, newFileUrl }) + logger.info('视频文件路径已成功更新到数据库', { + videoId, + fileId: record.fileId, + oldPath: updatedFile.path !== newPath ? '已更新' : '未知', + newPath, + newFileUrl + }) } catch (error) { logger.error('重新定位视频文件时出错', { error }) + // 可以考虑向用户显示错误提示 } }, [videoData, videoId] diff --git a/src/renderer/src/pages/player/components/VideoErrorRecovery.tsx b/src/renderer/src/pages/player/components/VideoErrorRecovery.tsx index cb9b561e..c3d2c81a 100644 --- a/src/renderer/src/pages/player/components/VideoErrorRecovery.tsx +++ b/src/renderer/src/pages/player/components/VideoErrorRecovery.tsx @@ -166,7 +166,7 @@ function VideoErrorRecovery({ width={480} closable={false} maskClosable={false} - destroyOnClose + destroyOnHidden > {videoTitle} diff --git a/src/renderer/src/pages/player/engine/MediaClock.ts b/src/renderer/src/pages/player/engine/MediaClock.ts index 0314ca74..8f02c8ad 100644 --- a/src/renderer/src/pages/player/engine/MediaClock.ts +++ b/src/renderer/src/pages/player/engine/MediaClock.ts @@ -214,13 +214,16 @@ class EventDeduplicator { // 使用 TimeMath 进行时间相等性检查 if (event.type === 'time_update') { - // 检查是否为相同时间点(在容差范围内) + // 检查是否为相同时间点(在容差范围内) - 减少重复日志 if (TimeMath.equals(existing.currentTime, event.currentTime)) { - logger.debug('Duplicate time_update event (TimeMath.equals)', { - existing: existing.currentTime, - current: event.currentTime, - epsilon: TimeMath.EPS - }) + // 降低重复事件日志频率,只记录采样的重复事件 + if (Math.random() < 0.05) { + logger.debug('Duplicate time_update event (TimeMath.equals - sampled)', { + existing: existing.currentTime, + current: event.currentTime, + epsilon: TimeMath.EPS + }) + } return true } @@ -232,11 +235,14 @@ class EventDeduplicator { const boundaries = [0, existing.currentTime, event.currentTime] // 可能的边界点 for (const boundary of boundaries) { if (TimeMath.detectBoundaryFlutter(this.timeHistory, boundary)) { - logger.debug('Boundary flutter detected, treating as duplicate', { - boundary, - timeHistory: this.timeHistory.slice(0, 3), - currentTime: event.currentTime - }) + // 减少边界抖动检测日志频率 + if (Math.random() < 0.1) { + logger.debug('Boundary flutter detected, treating as duplicate (sampled)', { + boundary, + timeHistory: this.timeHistory.slice(0, 3), + currentTime: event.currentTime + }) + } return true } } @@ -608,9 +614,9 @@ export class MediaClock { playbackRate: this.state.playbackRate } - // 在高精度模式下添加额外的调试信息 - if (this.throttler.getMode() === ThrottleMode.HIGH_PRECISION) { - logger.debug('High-precision time update', { + // 在高精度模式下添加额外的调试信息 - 降低频率 + if (this.throttler.getMode() === ThrottleMode.HIGH_PRECISION && Math.random() < 0.1) { + logger.debug('High-precision time update (sampled)', { previousTime, currentTime, epsilon, diff --git a/src/renderer/src/pages/player/engine/PlayerOrchestrator.ts b/src/renderer/src/pages/player/engine/PlayerOrchestrator.ts index 8b9e82af..798f613f 100644 --- a/src/renderer/src/pages/player/engine/PlayerOrchestrator.ts +++ b/src/renderer/src/pages/player/engine/PlayerOrchestrator.ts @@ -56,6 +56,7 @@ export interface StateUpdater { setMuted(muted: boolean): void setSeeking?(seeking: boolean): void setEnded?(ended: boolean): void + setActiveCueIndex?(index: number): void updateUIState(updates: { openAutoResumeCountdown?: boolean }): void } @@ -203,6 +204,12 @@ export class PlayerOrchestrator { */ connectStateUpdater(updater: StateUpdater): void { this.stateUpdater = updater + + // 立即同步当前的 activeCueIndex 到 store + if (updater.setActiveCueIndex) { + updater.setActiveCueIndex(this.context.activeCueIndex) + } + logger.debug('State updater connected') } @@ -220,6 +227,11 @@ export class PlayerOrchestrator { const prevContext = { ...this.context } this.context = { ...this.context, ...updates } + // 同步 activeCueIndex 到 store(如果有变化) + if (updates.activeCueIndex !== undefined && this.stateUpdater?.setActiveCueIndex) { + this.stateUpdater.setActiveCueIndex(updates.activeCueIndex) + } + if (this.config.enableDebugLogs) { const changedFields = Object.keys(updates).filter( (key) => prevContext[key as keyof PlaybackContext] !== updates[key as keyof PlaybackContext] @@ -418,6 +430,18 @@ export class PlayerOrchestrator { // 重置播放器状态(清理意图、重置字幕锁定、重载策略) this.resetOnUserSeek() + // 标记用户跳转状态,暂时禁用自动保存 + import('@renderer/services/PlayerSettingsSaver').then( + ({ playerSettingsPersistenceService }) => { + playerSettingsPersistenceService.markUserSeeking() + } + ) + + // 立即更新 store 中的 currentTime,确保 UI 组件能立即响应 + if (this.stateUpdater) { + this.stateUpdater.setCurrentTime(to) + } + // 执行跳转 const clampedTime = Math.max(0, Math.min(this.context.duration || Infinity, to)) this.videoController.seek(clampedTime) @@ -451,8 +475,33 @@ export class PlayerOrchestrator { // 重置播放器状态(清理意图、重置字幕锁定、重载策略) this.resetOnUserSeek() - this.context.currentTime = cue.startTime - this.context.activeCueIndex = index + + // 立即锁定字幕状态机,防止 SubtitleSyncStrategy 在 updateContext 时覆盖用户选择 + this.subtitleLockFSM.lock('user_seek', index) + + this.updateContext({ currentTime: cue.startTime, activeCueIndex: index }) + + // 设置定时器,2秒后自动解锁,允许自动同步策略重新生效 + this.userSeekTaskId = this.clockScheduler.scheduleAfter( + 2000, // 2秒延迟 + () => { + this.subtitleLockFSM.unlock('user_seek') + this.userSeekTaskId = null + logger.debug('用户跳转锁定已自动解除') + }, + 'user_seek_unlock' + ) + // 标记用户跳转状态,暂时禁用自动保存 + import('@renderer/services/PlayerSettingsSaver').then( + ({ playerSettingsPersistenceService }) => { + playerSettingsPersistenceService.markUserSeeking() + } + ) + + // 立即更新 store 中的 currentTime,确保字幕 overlay 能立即响应 + if (this.stateUpdater) { + this.stateUpdater.setCurrentTime(cue.startTime) + } // 执行跳转 const clampedTime = Math.max(0, Math.min(this.context.duration || Infinity, cue.startTime)) @@ -572,7 +621,8 @@ export class PlayerOrchestrator { onTimeUpdate(currentTime: number): void { this.mediaClock.updateTime(currentTime) - logger.silly('onTimeUpdate', { currentTime }) + // 移除高频的 silly 日志,减少内存压力 + // logger.silly('onTimeUpdate', { currentTime }) } onPlay(): void { @@ -589,6 +639,7 @@ export class PlayerOrchestrator { } onSeeking(): void { + this.mediaClock.startSeeking() this.stateUpdater?.setSeeking?.(true) } @@ -661,8 +712,6 @@ export class PlayerOrchestrator { * 清理未发布的意图、重置字幕锁定状态、重载所有策略 */ private resetOnUserSeek(): void { - logger.debug('用户跳转,重置播放器状态') - // 清理未发布的意图 if (this.currentIntents.length > 0) { logger.debug(`清理 ${this.currentIntents.length} 个未发布的意图`) @@ -682,7 +731,7 @@ export class PlayerOrchestrator { // 重载策略管理器 this.strategyManager.reload(currentStrategies) - logger.debug('播放器状态重置完成') + logger.debug('用户跳转,播放器状态重置完成') } private registerBuiltinStrategies(): void { @@ -1214,9 +1263,9 @@ export class PlayerOrchestrator { } } - // 存储追踪记录(最多 200 条) + // 存储追踪记录(减少到最多 50 条,优化内存使用) this._traceBuf.push(traceRecord) - if (this._traceBuf.length > 200) this._traceBuf.shift() + if (this._traceBuf.length > 50) this._traceBuf.shift() // 根据配置输出日志 if (this.config.enableDebugLogs) { diff --git a/src/renderer/src/pages/player/engine/intent/IntentStrategyManager.ts b/src/renderer/src/pages/player/engine/intent/IntentStrategyManager.ts index 841b068f..65572398 100644 --- a/src/renderer/src/pages/player/engine/intent/IntentStrategyManager.ts +++ b/src/renderer/src/pages/player/engine/intent/IntentStrategyManager.ts @@ -190,7 +190,6 @@ export class IntentStrategyManager { for (const strategy of strategiesToReload) { try { this.registerStrategy(strategy) - logger.debug(`重新挂载策略: ${strategy.name}`) } catch (error) { logger.error(`重新挂载策略 ${strategy.name} 失败:`, { error }) } diff --git a/src/renderer/src/pages/player/hooks/usePlayerEngine.ts b/src/renderer/src/pages/player/hooks/usePlayerEngine.ts index dadbe81c..b86f32ac 100644 --- a/src/renderer/src/pages/player/hooks/usePlayerEngine.ts +++ b/src/renderer/src/pages/player/hooks/usePlayerEngine.ts @@ -55,6 +55,10 @@ function getOrCreateGlobalStateUpdater(): StateUpdater { // TODO: 如果需要,可以在 player store 中添加 ended 状态 logger.debug('Ended state updated:', { ended }) }, + setActiveCueIndex: (index: number) => { + usePlayerStore.getState().setActiveCueIndex(index) + logger.debug('Active cue index updated:', { index }) + }, // UI状态更新处理 updateUIState: (updates: { openAutoResumeCountdown?: boolean }) => { logger.debug('Processing UI state updates:', { updates }) diff --git a/src/renderer/src/pages/player/hooks/usePlayerShortcuts.ts b/src/renderer/src/pages/player/hooks/usePlayerShortcuts.ts index 51ea4612..fe25ccc3 100644 --- a/src/renderer/src/pages/player/hooks/usePlayerShortcuts.ts +++ b/src/renderer/src/pages/player/hooks/usePlayerShortcuts.ts @@ -32,7 +32,7 @@ const logger = loggerService.withContext('TransportBar') export function usePlayerShortcuts() { const { t } = useTranslation() const cmd = usePlayerCommands() - const { setDisplayMode, currentSubtitle } = useSubtitleOverlay() + const { currentSubtitle } = useSubtitleOverlay() const { toggleSubtitlePanel, cycleFavoriteRateNext, cycleFavoriteRatePrev } = usePlayerStore() const displayMode = usePlayerStore((s) => s.subtitleOverlay.displayMode) @@ -133,27 +133,6 @@ export function usePlayerShortcuts() { cmd.toggleLoopEnabled() }) - // 字幕显示模式切换 - useShortcut('subtitle_mode_none', () => { - setDisplayMode(SubtitleDisplayMode.NONE) - logger.info('字幕显示模式切换: 隐藏') - }) - - useShortcut('subtitle_mode_original', () => { - setDisplayMode(SubtitleDisplayMode.ORIGINAL) - logger.info('字幕显示模式切换: 仅原文') - }) - - useShortcut('subtitle_mode_translated', () => { - setDisplayMode(SubtitleDisplayMode.TRANSLATED) - logger.info('字幕显示模式切换: 仅译文') - }) - - useShortcut('subtitle_mode_bilingual', () => { - setDisplayMode(SubtitleDisplayMode.BILINGUAL) - logger.info('字幕显示模式切换: 双语显示') - }) - // 字幕面板切换 useShortcut('toggle_subtitle_panel', () => { toggleSubtitlePanel() diff --git a/src/renderer/src/pages/player/hooks/useSubtitleEngine.ts b/src/renderer/src/pages/player/hooks/useSubtitleEngine.ts index e1438e4c..d0b94c1e 100644 --- a/src/renderer/src/pages/player/hooks/useSubtitleEngine.ts +++ b/src/renderer/src/pages/player/hooks/useSubtitleEngine.ts @@ -19,6 +19,7 @@ interface SubtitleEngine { export function useSubtitleEngine(): SubtitleEngine { const subtitles = useSubtitles() const currentTime = usePlayerStore((s) => s.currentTime) + const storeActiveCueIndex = usePlayerStore((s) => s.activeCueIndex) // 创建时间索引,用于二分查找优化 const timeIndex = useMemo(() => { @@ -78,10 +79,15 @@ export function useSubtitleEngine(): SubtitleEngine { } }, [findIndexByTime, subtitles]) - // 当前字幕和索引 + // 当前字幕和索引 - 优先使用 PlayerOrchestrator 的 activeCueIndex,回退到基于时间的计算 const currentIndex = useMemo(() => { + // 如果 PlayerOrchestrator 提供了有效的 activeCueIndex,直接使用 + if (storeActiveCueIndex >= 0 && storeActiveCueIndex < subtitles.length) { + return storeActiveCueIndex + } + // 否则回退到基于时间的计算 return findIndexByTime(currentTime) - }, [findIndexByTime, currentTime]) + }, [storeActiveCueIndex, subtitles.length, findIndexByTime, currentTime]) const currentSubtitle = useMemo(() => { return currentIndex >= 0 ? subtitles[currentIndex] : null diff --git a/src/renderer/src/pages/player/hooks/useSubtitleOverlay.ts b/src/renderer/src/pages/player/hooks/useSubtitleOverlay.ts index 4470f5f4..7e1dd001 100644 --- a/src/renderer/src/pages/player/hooks/useSubtitleOverlay.ts +++ b/src/renderer/src/pages/player/hooks/useSubtitleOverlay.ts @@ -55,41 +55,77 @@ export function useSubtitleOverlay(): SubtitleOverlay { } }, [currentSubtitle, currentIndex]) + // === 缓存当前字幕数据以防止不必要的重新渲染 === + const stableCurrentSubtitle = useMemo(() => { + if (!currentSubtitleData) return null + + // 只有当内容实际变化时才返回新对象 + return { + originalText: currentSubtitleData.originalText, + translatedText: currentSubtitleData.translatedText, + startTime: currentSubtitleData.startTime, + endTime: currentSubtitleData.endTime, + index: currentSubtitleData.index + } + }, [ + currentSubtitleData?.originalText, + currentSubtitleData?.translatedText, + currentSubtitleData?.startTime, + currentSubtitleData?.endTime, + currentSubtitleData?.index + ]) + // === 计算是否应该显示 === const shouldShow = useMemo(() => { // 基础条件:显示模式不为 NONE 且有字幕数据 - if (subtitleOverlayConfig.displayMode === SubtitleDisplayMode.NONE || !currentSubtitleData) { + if (subtitleOverlayConfig.displayMode === SubtitleDisplayMode.NONE || !stableCurrentSubtitle) { return false } - // 时间边界检查:确保当前播放时间在字幕的时间范围内 + // 优先检查:如果当前字幕索引与 engine 提供的索引一致,说明这是权威数据,直接显示 + // 这可以避免用户跳转时因时间不同步导致的闪烁 + if (currentIndex >= 0 && stableCurrentSubtitle.index === currentIndex) { + return true + } + + // 正常的时间边界检查:确保当前播放时间在字幕的时间范围内 const isInTimeRange = - currentTime >= currentSubtitleData.startTime && currentTime <= currentSubtitleData.endTime + currentTime >= stableCurrentSubtitle.startTime && currentTime <= stableCurrentSubtitle.endTime + + // 如果在时间范围内,直接显示 + if (isInTimeRange) { + return true + } + + // 智能容差机制:处理播放时的短暂时间不同步问题 + // 如果当前时间接近字幕开始时间,也应该显示(防止跳转闪烁) + const timeDiffToStart = Math.abs(currentTime - stableCurrentSubtitle.startTime) + const isNearStart = timeDiffToStart <= 2.0 // 2秒容差,处理跳转延迟 - return isInTimeRange - }, [subtitleOverlayConfig.displayMode, currentSubtitleData, currentTime]) + return isNearStart + }, [subtitleOverlayConfig.displayMode, stableCurrentSubtitle, currentTime, currentIndex]) // === 计算显示文本 === const displayText = useMemo(() => { - if (!currentSubtitleData || !shouldShow || !subtitleOverlayConfig) return '' + if (!stableCurrentSubtitle || !shouldShow || !subtitleOverlayConfig) return '' switch (subtitleOverlayConfig.displayMode) { case SubtitleDisplayMode.ORIGINAL: - return currentSubtitleData.originalText + return stableCurrentSubtitle.originalText case SubtitleDisplayMode.TRANSLATED: - return currentSubtitleData.translatedText || currentSubtitleData.originalText + return stableCurrentSubtitle.translatedText || stableCurrentSubtitle.originalText case SubtitleDisplayMode.BILINGUAL: - if (currentSubtitleData.translatedText) { - return `${currentSubtitleData.originalText}\n${currentSubtitleData.translatedText}` + if (stableCurrentSubtitle.translatedText) { + return `${stableCurrentSubtitle.originalText}\n${stableCurrentSubtitle.translatedText}` } - return currentSubtitleData.originalText + return stableCurrentSubtitle.originalText default: return '' } - }, [subtitleOverlayConfig, currentSubtitleData, shouldShow]) + }, [subtitleOverlayConfig, stableCurrentSubtitle, shouldShow]) // === 配置操作的包装器(添加 PlayerStore 同步) === const setDisplayModeWithSync = useCallback( @@ -141,7 +177,7 @@ export function useSubtitleOverlay(): SubtitleOverlay { ) return { - currentSubtitle: currentSubtitleData, + currentSubtitle: stableCurrentSubtitle, shouldShow, displayText, setDisplayMode: setDisplayModeWithSync, diff --git a/src/renderer/src/pages/settings/FFmpegSettings.tsx b/src/renderer/src/pages/settings/FFmpegSettings.tsx new file mode 100644 index 00000000..84295859 --- /dev/null +++ b/src/renderer/src/pages/settings/FFmpegSettings.tsx @@ -0,0 +1,619 @@ +import { loggerService } from '@logger' +import IndicatorLight from '@renderer/components/IndicatorLight' +import { useTheme } from '@renderer/contexts' +import { + ANIMATION_DURATION, + BORDER_RADIUS, + EASING, + FONT_SIZES, + FONT_WEIGHTS, + SPACING +} from '@renderer/infrastructure/styles/theme' +import { Button, Input, message, Popconfirm, Space } from 'antd' +import { CheckCircle, Download, FolderOpen, RefreshCw, Trash2 } from 'lucide-react' +import { FC, useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useLocation } from 'react-router-dom' +import styled from 'styled-components' + +import { + SettingContainer, + SettingDescription, + SettingDivider, + SettingGroup, + SettingRow, + SettingRowTitle, + SettingTitle +} from '.' + +const logger = loggerService.withContext('FFmpegSettings') + +interface FFmpegStatus { + path: string + isBundled: boolean + isDownloaded: boolean + isSystemFFmpeg: boolean + platform: string + arch: string + version?: string + needsDownload: boolean +} + +interface FFmpegWarmupStatus { + isWarmedUp: boolean + isWarming: boolean +} + +interface FFmpegDownloadProgress { + percent?: number + downloaded?: number + total?: number + speed?: number + remainingTime?: number + status?: 'downloading' | 'extracting' | 'verifying' | 'completed' | 'error' +} + +const FFmpegSettings: FC = () => { + const { theme } = useTheme() + const { t } = useTranslation() + const location = useLocation() + + // 状态管理 + const [ffmpegStatus, setFFmpegStatus] = useState(null) + const [warmupStatus, setWarmupStatus] = useState({ + isWarmedUp: false, + isWarming: false + }) + const [ffmpegPath, setFFmpegPath] = useState('') + const [isDownloading, setIsDownloading] = useState(false) + const [downloadProgress, setDownloadProgress] = useState({}) + const [showSuccessState, setShowSuccessState] = useState(false) + const isCancellingRef = useRef(false) + const isCompletionHandledRef = useRef(false) + const [isValidatingPath, setIsValidatingPath] = useState(false) + + // 获取 FFmpeg 状态 + const fetchFFmpegStatus = useCallback(async () => { + try { + const status = await window.api.ffmpeg.getInfo() + setFFmpegStatus(status) + + // 同步 FFmpeg 路径 + if (status.path) { + setFFmpegPath(status.path) + } + + const warmup = await window.api.ffmpeg.getWarmupStatus() + setWarmupStatus(warmup) + } catch (error) { + logger.error('获取 FFmpeg 状态失败:', { error }) + } + }, []) + + // 初始化时获取状态 + useEffect(() => { + fetchFFmpegStatus() + }, [fetchFFmpegStatus]) + + // 预热 FFmpeg + const handleWarmup = useCallback(async () => { + try { + setWarmupStatus((prev) => ({ ...prev, isWarming: true })) + const result = await window.api.ffmpeg.warmup() + + if (result) { + setWarmupStatus({ isWarmedUp: true, isWarming: false }) + message.success(t('settings.plugins.ffmpeg.download.warmup_success')) + } else { + setWarmupStatus({ isWarmedUp: false, isWarming: false }) + message.error(t('settings.plugins.ffmpeg.download.warmup_failed')) + } + } catch (error) { + setWarmupStatus({ isWarmedUp: false, isWarming: false }) + message.error(t('settings.plugins.ffmpeg.download.warmup_failed')) + logger.error('预热失败:', { error }) + } + }, [t]) + + // 下载进度轮询 + useEffect(() => { + let progressInterval: NodeJS.Timeout | null = null + + if (isDownloading) { + progressInterval = setInterval(async () => { + try { + const progress = await window.api.ffmpeg.download.getProgress() + setDownloadProgress(progress || {}) + + // 检查下载是否完成 + const currentStatus = await window.api.ffmpeg.getInfo() + if ( + currentStatus.isDownloaded && + !currentStatus.needsDownload && + !isCompletionHandledRef.current + ) { + // 标记已处理,防止重复 + isCompletionHandledRef.current = true + + // 立即停止轮询 + if (progressInterval) { + clearInterval(progressInterval) + progressInterval = null + } + + // 先显示成功状态 + setShowSuccessState(true) + message.success(t('settings.plugins.ffmpeg.download.success')) + + // 2秒后恢复正常状态 + setTimeout(() => { + setIsDownloading(false) + setShowSuccessState(false) + setFFmpegStatus(currentStatus) + // 更新 FFmpeg 路径为下载后的路径 + setFFmpegPath(currentStatus.path) + // 自动开始预热 + handleWarmup() + }, 2000) + } + } catch (error) { + logger.error('获取下载进度失败:', { error }) + } + }, 2000) + } + + return () => { + if (progressInterval) { + clearInterval(progressInterval) + } + } + }, [handleWarmup, isDownloading, t]) + + // 下载 FFmpeg + const handleDownload = useCallback(async () => { + try { + isCancellingRef.current = false // 重置取消标志 + isCompletionHandledRef.current = false // 重置完成处理标志 + setIsDownloading(true) + setDownloadProgress({ percent: 0 }) + + const result = await window.api.ffmpeg.download.download() + if (!result) { + throw new Error('下载失败') + } + } catch (error) { + setIsDownloading(false) + // 如果是用户主动取消,不显示失败message + if (!isCancellingRef.current) { + message.error(t('settings.plugins.ffmpeg.download.failed')) + logger.error('下载 FFmpeg 失败:', { error }) + } + } + }, [t]) + + // 检查URL参数,触发自动下载 + useEffect(() => { + const searchParams = new URLSearchParams(location.search) + const shouldAutoDownload = searchParams.get('autoDownload') === 'true' + + if (shouldAutoDownload && ffmpegStatus?.needsDownload && !isDownloading) { + // 延迟一点时间确保UI已经渲染 + const timer = setTimeout(() => { + handleDownload() + }, 500) + + return () => clearTimeout(timer) + } + + // 确保所有分支都有返回值 + return undefined + }, [location.search, ffmpegStatus?.needsDownload, isDownloading, handleDownload]) + + // 取消下载 + const handleCancelDownload = useCallback(async () => { + try { + isCancellingRef.current = true + await window.api.ffmpeg.download.cancel() + setIsDownloading(false) + setDownloadProgress({}) + message.info(t('settings.plugins.ffmpeg.download.cancelled')) + } catch (error) { + logger.error('取消下载失败:', { error }) + } finally { + // 延迟重置,确保下载函数的catch能够检测到 + setTimeout(() => { + isCancellingRef.current = false + }, 100) + } + }, [t]) + + // 选择文件路径 + const handleBrowsePath = useCallback(async () => { + try { + const result = await window.api.select({ + title: t('settings.plugins.ffmpeg.path.browse_title'), + properties: ['openFile'], + filters: [{ name: 'FFmpeg 可执行文件', extensions: ['exe', 'app', '*'] }] + }) + + if (result && result.filePaths && result.filePaths.length > 0) { + setFFmpegPath(result.filePaths[0]) + } + } catch (error) { + logger.error('选择路径失败:', { error }) + } + }, [t]) + + // 验证 FFmpeg 路径 + const validateFFmpegPath = useCallback( + async (path: string) => { + if (!path.trim()) return + + setIsValidatingPath(true) + try { + // 检查文件是否存在 + const exists = await window.api.fs.checkFileExists(path) + if (!exists) { + message.warning(t('settings.plugins.ffmpeg.path.invalid')) + return false + } + + // 这里可以进一步验证是否是有效的 FFmpeg 可执行文件 + // 例如执行 ffmpeg -version 命令检查 + message.success(t('settings.plugins.ffmpeg.path.valid')) + return true + } catch (error) { + logger.error('验证路径失败:', { error }) + message.error(t('settings.plugins.ffmpeg.path.validation_failed')) + return false + } finally { + setIsValidatingPath(false) + } + }, + [t] + ) + + // 卸载 FFmpeg + const handleUninstall = useCallback(async () => { + try { + const result = await window.api.ffmpeg.download.remove() + if (result) { + message.success(t('settings.plugins.ffmpeg.uninstall.success')) + // 卸载后重新获取状态,路径会自动更新 + await fetchFFmpegStatus() + } else { + message.error(t('settings.plugins.ffmpeg.uninstall.failed')) + } + } catch (error) { + message.error(t('settings.plugins.ffmpeg.uninstall.failed')) + logger.error('卸载 FFmpeg 失败:', { error }) + } + }, [t, fetchFFmpegStatus]) + + // 获取状态显示信息 + const getStatusInfo = () => { + if (!ffmpegStatus) { + return { + text: t('settings.plugins.ffmpeg.status.loading'), + color: 'gray' as const, + pulsing: true + } + } + + if (isDownloading) { + return { + text: t('settings.plugins.ffmpeg.status.downloading'), + color: 'blue' as const, + pulsing: true + } + } + + if (warmupStatus.isWarming) { + return { + text: t('settings.plugins.ffmpeg.download.warming_up'), + color: 'yellow' as const, + pulsing: true + } + } + + if (ffmpegStatus.needsDownload) { + return { + text: t('settings.plugins.ffmpeg.status.not_installed'), + color: 'red' as const, + pulsing: false + } + } + + if (ffmpegStatus.isSystemFFmpeg) { + return { + text: t('settings.plugins.ffmpeg.status.system_version'), + color: 'green' as const, + pulsing: false + } + } + + if (ffmpegStatus.isDownloaded || ffmpegStatus.isBundled) { + return { + text: warmupStatus.isWarmedUp + ? t('settings.plugins.ffmpeg.status.available') + : t('settings.plugins.ffmpeg.status.installed'), + color: 'green' as const, + pulsing: false + } + } + + return { + text: t('settings.plugins.ffmpeg.status.unknown'), + color: 'gray' as const, + pulsing: false + } + } + + const statusInfo = getStatusInfo() + const downloadProgressPercent = downloadProgress.percent || 0 + + return ( + + + {t('settings.plugins.ffmpeg.title')} + {t('settings.plugins.ffmpeg.description')} + + + {/* 状态显示 */} + + {t('settings.plugins.ffmpeg.status.label')} + + {statusInfo.text} + + + + + {/* 版本信息 */} + {ffmpegStatus?.version && ( + <> + + + {t('settings.plugins.ffmpeg.version')} + {ffmpegStatus.version} + + + )} + + {/* FFmpeg 路径 */} + + + {t('settings.plugins.ffmpeg.path.label')} + + setFFmpegPath(e.target.value)} + onBlur={() => validateFFmpegPath(ffmpegPath)} + placeholder={t('settings.plugins.ffmpeg.path.placeholder')} + suffix={isValidatingPath ? : null} + /> + + + + + {/* 操作按钮 */} + + + {t('settings.plugins.ffmpeg.actions.label')} + + {ffmpegStatus?.needsDownload ? ( + : } + onClick={handleDownload} + disabled={isDownloading || showSuccessState} + $isDownloading={isDownloading} + $downloadProgress={downloadProgressPercent} + $showSuccessState={showSuccessState} + > + + {showSuccessState + ? t('settings.plugins.ffmpeg.download.success') + : isDownloading + ? `${t('settings.plugins.ffmpeg.download.downloading')} ${downloadProgressPercent.toFixed(0)}%` + : t('settings.plugins.ffmpeg.download.button')} + + {isDownloading && } + + ) : ( + + + + {(ffmpegStatus?.isDownloaded || ffmpegStatus?.isBundled) && + !ffmpegStatus?.isSystemFFmpeg && ( + + + + )} + + )} + + {isDownloading && ( + + {t('settings.plugins.ffmpeg.download.cancel')} + + )} + + + + + ) +} + +// 样式组件 +const StatusContainer = styled.div` + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; +` + +// 下载按钮容器 +const DownloadButtonContainer = styled.div` + display: flex; + gap: ${SPACING.XS}px; + align-items: flex-start; + flex-direction: column; + + @media (min-width: 640px) { + flex-direction: row; + align-items: center; + } +` + +// 增强的下载按钮 +const DownloadButton = styled(Button)<{ + $isDownloading: boolean + $downloadProgress: number + $showSuccessState?: boolean +}>` + position: relative; + min-width: 160px; + height: 32px; + padding: ${SPACING.XXS}px ${SPACING.SM}px; + overflow: hidden; + border-radius: ${BORDER_RADIUS.SM}px; + transition: all ${ANIMATION_DURATION.MEDIUM} ${EASING.APPLE}; + + // 确保内容在进度条之上 + .ant-btn-content { + position: relative; + z-index: 2; + width: 100%; + } + + // 禁用状态样式 + &.ant-btn-primary[disabled] { + background: ${({ $showSuccessState }) => + $showSuccessState ? 'var(--ant-color-success)' : 'var(--ant-color-primary)'}; + border-color: ${({ $showSuccessState }) => + $showSuccessState ? 'var(--ant-color-success)' : 'var(--ant-color-primary)'}; + color: var(--ant-color-white); + opacity: 1; + transform: ${({ $showSuccessState }) => ($showSuccessState ? 'scale(1.02)' : 'none')}; + } + + // 悬停效果 + &:not([disabled]):hover { + transform: translateY(-1px); + box-shadow: var(--ant-box-shadow-secondary); + } +` + +// 按钮文本 +const DownloadButtonText = styled.span` + font-weight: ${FONT_WEIGHTS.SEMIBOLD}; + font-size: ${FONT_SIZES.SM}px; + line-height: 1.2; + position: relative; + z-index: 2; +` + +// 进度条 +const DownloadProgressBar = styled.div<{ $progress: number }>` + position: absolute; + bottom: 0; + left: 0; + height: 2px; + width: ${({ $progress }) => $progress}%; + background: linear-gradient( + 90deg, + rgba(255, 255, 255, 0.3) 0%, + rgba(255, 255, 255, 0.6) 50%, + rgba(255, 255, 255, 0.3) 100% + ); + border-radius: 0 0 ${BORDER_RADIUS.SM}px ${BORDER_RADIUS.SM}px; + transition: width ${ANIMATION_DURATION.MEDIUM} ${EASING.APPLE}; + z-index: 1; + + // 添加光效动画 + &::after { + content: ''; + position: absolute; + top: 0; + right: -20px; + width: 20px; + height: 100%; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.4) 50%, + transparent 100% + ); + animation: shimmer 2s infinite; + } + + @keyframes shimmer { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(100%); + } + } +` + +// 操作按钮组 +const ActionButtonGroup = styled(Space)`` + +// 取消按钮 +const CancelButton = styled(Button)` + font-size: ${FONT_SIZES.XS}px; + height: 32px; + padding: 0 ${SPACING.SM}px; + border-radius: ${BORDER_RADIUS.SM}px; + transition: all ${ANIMATION_DURATION.MEDIUM} ${EASING.APPLE}; + + &:hover { + transform: translateY(-1px); + } +` + +const PathInputContainer = styled.div` + display: flex; + gap: 8px; + align-items: center; + + .ant-input { + flex: 1; + max-width: 250px; + } + + .spin { + animation: spin 1s linear infinite; + } + + @keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } + } +` + +export default FFmpegSettings diff --git a/src/renderer/src/pages/settings/SettingsPage.tsx b/src/renderer/src/pages/settings/SettingsPage.tsx index a51ed41d..5359ccae 100644 --- a/src/renderer/src/pages/settings/SettingsPage.tsx +++ b/src/renderer/src/pages/settings/SettingsPage.tsx @@ -1,5 +1,5 @@ import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar' -import { Command, Eye, Info, PlayCircle, Settings2 } from 'lucide-react' +import { Command, Eye, Info, Monitor, PlayCircle, Settings2 } from 'lucide-react' import React from 'react' import { useTranslation } from 'react-i18next' import { Link, Route, Routes, useLocation } from 'react-router-dom' @@ -7,6 +7,7 @@ import styled from 'styled-components' import AboutSettings from './AboutSettings' import { AppearanceSettings } from './AppearanceSettings' +import FFmpegSettings from './FFmpegSettings' import GeneralSettings from './GeneralSettings' import PlaybackSettings from './PlaybackSettings' import ShortcutSettings from './ShortcutSettings' @@ -51,6 +52,12 @@ export function SettingsPage(): React.JSX.Element { {t('settings.playback.title')} + + + + {t('settings.plugins.title')} + + @@ -64,6 +71,7 @@ export function SettingsPage(): React.JSX.Element { } /> } /> } /> + } /> } /> diff --git a/src/renderer/src/services/Logger.ts b/src/renderer/src/services/Logger.ts index 38ca3f57..9988f15d 100644 --- a/src/renderer/src/services/Logger.ts +++ b/src/renderer/src/services/Logger.ts @@ -8,8 +8,9 @@ const IS_WORKER = typeof window === 'undefined' // DO NOT use `constants.ts` here, because the files contains other dependencies that will fail in worker process const IS_DEV = IS_WORKER ? false : window.electron?.process?.env?.NODE_ENV === 'development' -const DEFAULT_LEVEL = IS_DEV ? LEVEL.SILLY : LEVEL.INFO -const MAIN_LOG_LEVEL = LEVEL.WARN +// 更严格的生产环境日志级别控制 +const DEFAULT_LEVEL = IS_DEV ? LEVEL.DEBUG : LEVEL.WARN // 生产环境默认只记录警告以上 +const MAIN_LOG_LEVEL = IS_DEV ? LEVEL.WARN : LEVEL.ERROR // 生产环境主进程只记录错误 // 日志导出相关接口 interface LogExportEntry { @@ -54,9 +55,12 @@ class LoggerService { private module: string = '' private context: Record = {} - // 日志导出相关 + // 日志导出相关 - 优化内存管理 private exportHistory: LogExportEntry[] = [] - private maxHistorySize: number = 10000 + private maxHistorySize: number = 1000 // 降低到 1000 条记录,减少内存占用 + private lastCleanupTime: number = 0 + private readonly CLEANUP_INTERVAL = 30000 // 30秒清理一次 + private readonly MEMORY_PRESSURE_THRESHOLD = 500 // 内存压力阈值 private constructor() { if (IS_DEV) { @@ -134,100 +138,82 @@ class LoggerService { return newLogger } - // ---- 对象完全序列化方法 ---- - private deepSerialize(obj: any, visited = new WeakSet()): any { + // ---- 轻量化序列化方法 - 优化内存使用 ---- + private lightSerialize(obj: any, maxDepth = 3, currentDepth = 0): any { + // 超过最大深度,简化处理 + if (currentDepth >= maxDepth) { + if (obj === null || obj === undefined) return obj + if (typeof obj === 'string') return obj.length > 100 ? obj.slice(0, 100) + '...' : obj + if (typeof obj === 'number' || typeof obj === 'boolean') return obj + return '[Max Depth Reached]' + } + // 处理基本类型 if (obj === null || typeof obj !== 'object') { return obj } - // 防止循环引用 - if (visited.has(obj)) { - return '[Circular Reference]' - } - visited.add(obj) - try { // 处理日期对象 if (obj instanceof Date) { - return { __type: 'Date', value: obj.toISOString() } + return obj.toISOString() } - // 处理错误对象 + // 处理错误对象 - 简化 if (obj instanceof Error) { return { __type: 'Error', name: obj.name, message: obj.message, - stack: obj.stack, - ...Object.getOwnPropertyNames(obj).reduce((acc, key) => { - acc[key] = this.deepSerialize((obj as any)[key], visited) - return acc - }, {} as any) + stack: obj.stack?.split('\n').slice(0, 3).join('\n') // 只保留前3行堆栈 } } - // 处理函数 + // 处理函数 - 极简化 if (typeof obj === 'function') { - return { - __type: 'Function', - name: obj.name, - toString: obj.toString() - } + return `[Function: ${obj.name || 'anonymous'}]` } - // 处理数组 + // 处理数组 - 限制长度 if (Array.isArray(obj)) { - return obj.map((item) => this.deepSerialize(item, visited)) + const maxItems = 10 + if (obj.length > maxItems) { + return [ + ...obj + .slice(0, maxItems) + .map((item) => this.lightSerialize(item, maxDepth, currentDepth + 1)), + `[... ${obj.length - maxItems} more items]` + ] + } + return obj.map((item) => this.lightSerialize(item, maxDepth, currentDepth + 1)) } - // 处理普通对象 + // 处理普通对象 - 只序列化可枚举属性 const result: any = {} + const keys = Object.keys(obj) + const maxKeys = 20 // 限制最多序列化20个属性 - // 获取所有属性(包括不可枚举的) - const keys = [ - ...Object.keys(obj), - ...Object.getOwnPropertyNames(obj).filter( - (key) => key !== 'constructor' && !Object.keys(obj).includes(key) - ) - ] - - for (const key of keys) { + for (let i = 0; i < Math.min(keys.length, maxKeys); i++) { + const key = keys[i] try { - const descriptor = Object.getOwnPropertyDescriptor(obj, key) - if (descriptor) { - if (descriptor.get || descriptor.set) { - // 处理 getter/setter - result[key] = { - __type: 'Property', - hasGetter: !!descriptor.get, - hasSetter: !!descriptor.set, - enumerable: descriptor.enumerable, - configurable: descriptor.configurable - } - } else { - // 普通属性 - result[key] = this.deepSerialize(descriptor.value, visited) - } + // 跳过可能导致循环引用的属性 + if (key.includes('parent') || key.includes('owner') || key.includes('target')) { + result[key] = '[Skipped - Potential Circular]' + continue } + result[key] = this.lightSerialize(obj[key], maxDepth, currentDepth + 1) } catch (error) { - result[key] = `[Error accessing property: ${ - error instanceof Error ? error.message : 'Unknown error' - }]` + result[key] = '[Serialization Error]' } } - // 添加原型信息 - const proto = Object.getPrototypeOf(obj) - if (proto && proto !== Object.prototype) { - result.__prototype = proto.constructor?.name || '[Unknown Prototype]' + if (keys.length > maxKeys) { + result['[...]'] = `${keys.length - maxKeys} more properties` } return result } catch (error) { - return `[Serialization Error: ${error instanceof Error ? error.message : 'Unknown error'}]` - } finally { - visited.delete(obj) + return '[Serialization Error]' } } @@ -520,7 +506,7 @@ class LoggerService { // ---- 日志导出方法 ---- /** - * 添加日志到导出历史 + * 添加日志到导出历史 - 优化内存管理,生产环境禁用 */ private addToExportHistory( level: LogLevel, @@ -528,25 +514,113 @@ class LoggerService { data: any[], caller?: string | null ): void { + // 生产环境完全禁用导出历史功能,节省内存 + if (!IS_DEV) { + return + } + + // 检查是否需要清理 + this.checkAndCleanupHistory() + + // 对于高频日志,采用采样策略 + if (this.shouldSkipForSampling(level, message)) { + return + } + const entry: LogExportEntry = { timestamp: new Date().toISOString(), level, module: this.module, window: this.window, message, - data: data.length > 0 ? data.map((item) => this.deepSerialize(item)) : undefined, - context: Object.keys(this.context).length > 0 ? this.deepSerialize(this.context) : undefined, + data: data.length > 0 ? data.map((item) => this.lightSerialize(item, 2)) : undefined, + context: + Object.keys(this.context).length > 0 ? this.lightSerialize(this.context, 2) : undefined, caller: caller || undefined } this.exportHistory.push(entry) - // 限制历史记录数量 + // 更积极的内存管理 if (this.exportHistory.length > this.maxHistorySize) { - this.exportHistory = this.exportHistory.slice(-this.maxHistorySize) + // 保留最近的记录,删除最旧的 + const keepCount = Math.floor(this.maxHistorySize * 0.8) // 保留80% + this.exportHistory.splice(0, this.exportHistory.length - keepCount) } } + /** + * 采样策略 - 对高频日志进行采样 + */ + private shouldSkipForSampling(level: LogLevel, message: string): boolean { + // 对于 SILLY 和 DEBUG 级别的高频消息进行采样 + if (level === LEVEL.SILLY || level === LEVEL.DEBUG) { + // 检查是否为高频消息模式 + const isHighFrequency = + message.includes('time_update') || + message.includes('MediaClock') || + message.includes('throttle') || + message.includes('performance') + + if (isHighFrequency) { + // 只保留每10条记录中的1条 + return Math.random() > 0.1 + } + } + return false + } + + /** + * 检查并清理历史记录 + */ + private checkAndCleanupHistory(): void { + const now = Date.now() + + // 定期清理 + if (now - this.lastCleanupTime > this.CLEANUP_INTERVAL) { + this.performCleanup() + this.lastCleanupTime = now + } + + // 内存压力检测 + if (this.exportHistory.length > this.MEMORY_PRESSURE_THRESHOLD) { + this.performEmergencyCleanup() + } + } + + /** + * 执行常规清理 + */ + private performCleanup(): void { + const oldLength = this.exportHistory.length + + // 清理30分钟前的记录 + const thirtyMinutesAgo = Date.now() - 30 * 60 * 1000 + this.exportHistory = this.exportHistory.filter((entry) => { + const entryTime = new Date(entry.timestamp).getTime() + return entryTime > thirtyMinutesAgo + }) + + if (oldLength !== this.exportHistory.length) { + console.log(`[LoggerService] 清理了 ${oldLength - this.exportHistory.length} 条过期日志记录`) + } + } + + /** + * 执行紧急清理 + */ + private performEmergencyCleanup(): void { + const oldLength = this.exportHistory.length + + // 只保留最近的记录 + const keepCount = Math.floor(this.maxHistorySize * 0.5) + this.exportHistory.splice(0, this.exportHistory.length - keepCount) + + console.warn( + `[LoggerService] 内存压力过大,执行紧急清理,删除了 ${oldLength - this.exportHistory.length} 条记录` + ) + } + /** * 导出日志 */ diff --git a/src/renderer/src/services/PlayerSettingsLoader.ts b/src/renderer/src/services/PlayerSettingsLoader.ts index 308119fb..1d566f68 100644 --- a/src/renderer/src/services/PlayerSettingsLoader.ts +++ b/src/renderer/src/services/PlayerSettingsLoader.ts @@ -41,6 +41,7 @@ export class PlayerSettingsService { duration: 0, paused: true, isFullscreen: false, + activeCueIndex: -1, // 从全局设置获取的默认值 volume: globalSettings.defaultVolume, @@ -179,6 +180,7 @@ export class PlayerSettingsService { duration: 0, paused: true, isFullscreen: false, + activeCueIndex: -1, // 从数据库恢复的设置 volume: dbData.volume, diff --git a/src/renderer/src/services/PlayerSettingsSaver.ts b/src/renderer/src/services/PlayerSettingsSaver.ts index 7550c16e..0696842b 100644 --- a/src/renderer/src/services/PlayerSettingsSaver.ts +++ b/src/renderer/src/services/PlayerSettingsSaver.ts @@ -38,6 +38,11 @@ export class PlayerSettingsPersistenceService { private debounceTimer: NodeJS.Timeout | null = null private readonly debounceMs = 1200 + // 用户跳转时暂时禁用自动保存的标志位 + private isUserSeeking = false + private userSeekingTimer: NodeJS.Timeout | null = null + private currentVideoId: number | null = null + attach(videoId: number) { this.detach() if (!videoId || videoId <= 0) { @@ -45,6 +50,8 @@ export class PlayerSettingsPersistenceService { return } + this.currentVideoId = videoId + // 订阅持久化切片变化(手动在回调内比较,避免类型不匹配问题) this.unsubscribe = usePlayerStore.subscribe((state, prevState) => { const slice = selectPersistedSlice(state) @@ -77,7 +84,63 @@ export class PlayerSettingsPersistenceService { clearTimeout(this.debounceTimer) this.debounceTimer = null } + if (this.debounceCurrentTimeTimer) { + clearTimeout(this.debounceCurrentTimeTimer) + this.debounceCurrentTimeTimer = null + } + if (this.userSeekingTimer) { + clearTimeout(this.userSeekingTimer) + this.userSeekingTimer = null + } this.lastSaved = null + this.isUserSeeking = false + this.currentVideoId = null + } + + /** + * 标记用户正在跳转,暂时禁用 currentTime 的自动保存 + */ + markUserSeeking() { + this.isUserSeeking = true + + // 取消可能已排队的进度保存任务,避免与用户跳转状态竞争 + if (this.debounceCurrentTimeTimer) { + clearTimeout(this.debounceCurrentTimeTimer) + this.debounceCurrentTimeTimer = null + } + + // 清除之前的定时器 + if (this.userSeekingTimer) { + clearTimeout(this.userSeekingTimer) + } + + // 在略长于 debounceCurrentTimeMs 的时间后恢复自动保存并立即保存一次 + const resumeAfterMs = this.debounceCurrentTimeMs + 200 + this.userSeekingTimer = setTimeout(async () => { + this.isUserSeeking = false + this.userSeekingTimer = null + + // 立即保存一次当前播放进度,确保用户跳转后的位置被记录 + if (this.currentVideoId) { + try { + const currentTime = usePlayerStore.getState().currentTime + await window.api.db.videoLibrary.updatePlayProgress(this.currentVideoId, currentTime) + logger.debug('用户跳转状态已恢复,立即保存当前进度', { + videoId: this.currentVideoId, + currentTime + }) + } catch (error) { + logger.error('用户跳转状态恢复时保存进度失败', { + videoId: this.currentVideoId, + error + }) + } + } + + logger.debug('用户跳转状态已恢复,重新启用进度自动保存') + }, resumeAfterMs) + + logger.debug('已标记用户跳转状态,暂时禁用进度自动保存') } private onSliceChanged(videoId: number, slice: PlayerSettings) { @@ -100,6 +163,12 @@ export class PlayerSettingsPersistenceService { } private onCurrentTimeChanged(videoId: number, currentTime: number) { + // 如果用户正在跳转,跳过自动保存 + if (this.isUserSeeking) { + logger.debug('用户正在跳转,跳过进度自动保存', { videoId, currentTime }) + return + } + if (this.debounceCurrentTimeTimer) clearTimeout(this.debounceCurrentTimeTimer) this.debounceCurrentTimeTimer = setTimeout(async () => { try { diff --git a/src/renderer/src/state/stores/player.store.ts b/src/renderer/src/state/stores/player.store.ts index 7520782b..24afa3f1 100644 --- a/src/renderer/src/state/stores/player.store.ts +++ b/src/renderer/src/state/stores/player.store.ts @@ -40,6 +40,7 @@ export interface PlayerState { paused: boolean volume: number // 0–1 muted: boolean + activeCueIndex: number // 当前活跃的字幕索引,-1 表示没有活跃字幕 /** 播放速度 */ playbackRate: number @@ -100,6 +101,7 @@ export interface PlayerActions { setVolume: (v: number) => void // 引擎专用:通过 orchestrator.requestSetVolume() 调用 setMuted: (m: boolean) => void // 引擎专用:通过 orchestrator.requestToggleMute() 调用 setPlaybackRate: (r: number) => void // 引擎专用:通过 orchestrator.requestSetPlaybackRate() 调用 + setActiveCueIndex: (index: number) => void // 引擎专用:由 orchestrator 字幕策略自动设置 // === 常用速度控制 === // 组件可调用:用户设置 @@ -150,6 +152,7 @@ const initialState: PlayerState = { paused: true, volume: 1, muted: false, + activeCueIndex: -1, playbackRate: 1, favoriteRates: [1.0], // 默认常用速度 currentFavoriteIndex: 1, // 默认选择 1.0x @@ -218,6 +221,7 @@ const createPlayerStore: StateCreator set((s: Draft) => void (s.muted = !!m)), setPlaybackRate: (r) => set((s: Draft) => void (s.playbackRate = Math.max(0.25, Math.min(3, r)))), + setActiveCueIndex: (index) => set((s: Draft) => void (s.activeCueIndex = index)), setFullscreen: (f) => set((s: Draft) => void (s.isFullscreen = !!f)), // 常用速度控制