diff --git a/electron-builder.yml b/electron-builder.yml index 4fb1f67f..294dbf73 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 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 4dac8c2e..09f14d5a 100644 --- a/package.json +++ b/package.json @@ -48,10 +48,10 @@ "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", + "release": "npm run build:release && electron-builder --publish onTagOrDraft", + "release:all": "npm run build:release && electron-builder --publish always", + "release:never": "npm run build:release && electron-builder --publish never", + "release:draft": "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", @@ -71,9 +71,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/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/FFmpegDownloadService.ts b/src/main/services/FFmpegDownloadService.ts new file mode 100644 index 00000000..dd85fcbf --- /dev/null +++ b/src/main/services/FFmpegDownloadService.ts @@ -0,0 +1,591 @@ +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' + } + } +} + +// 镜像源配置 - TODO: 将来实现镜像源切换 +// const MIRROR_SOURCES = { +// china: { +// github: 'https://ghproxy.com/', // GitHub 代理 +// evermeet: 'https://cdn.example.cn/ffmpeg/', // 假设的国内镜像 +// johnvansickle: 'https://cdn.example.cn/ffmpeg/' // 假设的国内镜像 +// }, +// global: { +// github: '', +// evermeet: '', +// johnvansickle: '' +// } +// } + +export class FFmpegDownloadService { + private downloadProgress = new Map() + private downloadController = new Map() + private readonly binariesDir: string + + constructor() { + // FFmpeg 存储在 userData/binaries/ffmpeg/ 目录 + this.binariesDir = path.join(app.getPath('userData'), 'binaries', 'ffmpeg') + this.ensureDir(this.binariesDir) + } + + /** + * 获取 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 { + return FFMPEG_VERSIONS[platform]?.[arch] || null + } + + /** + * 获取所有支持的平台配置 + */ + public getAllSupportedVersions(): FFmpegVersion[] { + const versions: FFmpegVersion[] = [] + for (const platformConfigs of Object.values(FFMPEG_VERSIONS)) { + 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 + } + + const version = this.getFFmpegVersion(platform, arch) + if (!version) { + logger.error('不支持的平台', { platform, arch }) + return false + } + + logger.info('开始下载 FFmpeg', { platform, arch, version: version.version }) + + 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/__tests__/FFmpegDownloadService.test.ts b/src/main/services/__tests__/FFmpegDownloadService.test.ts new file mode 100644 index 00000000..0c5c6e6e --- /dev/null +++ b/src/main/services/__tests__/FFmpegDownloadService.test.ts @@ -0,0 +1,241 @@ +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') + +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', () => { + 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') + }) + }) + + 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) + }) + }) +}) 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/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/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/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/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 { } /> } /> } /> + } /> } />