From 8d1ca897849590fbe136a305cd0ba01adcc3b719 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Sat, 13 Sep 2025 09:04:21 +0800 Subject: [PATCH 01/12] fix(updater): remove detailed release notes from system update dialog (#152) - Remove detail parameter from system MessageBox to prevent window overflow - Display only essential update information with version number - Direct users to Settings > About page for full release notes - Remove unused formatReleaseNotes method and ReleaseNoteInfo interface Fixes issue where long release notes caused system dialog to overflow screen height, hiding the Install button. --- src/main/services/AppUpdater.ts | 26 +------------------------- 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index 611ae76a..879e3b44 100644 --- a/src/main/services/AppUpdater.ts +++ b/src/main/services/AppUpdater.ts @@ -274,21 +274,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 +294,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 +315,3 @@ interface GithubReleaseInfo { created_at: string }> } -interface ReleaseNoteInfo { - readonly version: string - readonly note: string | null -} From fe7caa4c7b3fe44236c414c2a4c952e93c247242 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Sat, 13 Sep 2025 10:05:30 +0800 Subject: [PATCH 02/12] remove: subtitle display mode keyboard shortcuts (#154) Remove all keyboard shortcuts for subtitle display modes: - subtitle_mode_none (Cmd/Ctrl+1) - subtitle_mode_original (Cmd/Ctrl+2) - subtitle_mode_translated (Cmd/Ctrl+3) - subtitle_mode_bilingual (Cmd/Ctrl+4) Changes: - Remove shortcut configurations from DEFAULT_SHORTCUTS - Remove shortcut handlers from usePlayerShortcuts hook - Remove i18n label references - Clean up unused imports and fix formatting Users can still change subtitle display modes through the UI interface. --- src/renderer/src/i18n/label.ts | 4 --- .../constants/shortcuts.const.ts | 28 ------------------- .../pages/player/hooks/usePlayerShortcuts.ts | 23 +-------------- 3 files changed, 1 insertion(+), 54 deletions(-) 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/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/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() From 5f139ab09de47236837d59abc0bd431fd3dd2017 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Sat, 13 Sep 2025 13:59:41 +0800 Subject: [PATCH 03/12] fix(logger): optimize logger memory management and reduce high-frequency logging (#156) - Reduce Logger export history from 10k to 1k entries and add automatic cleanup - Implement lightweight serialization to replace deep serialization - Add sampling strategy for high-frequency logs (time_update, MediaClock events) - Disable export history completely in production environment - Optimize MediaClock logging with 5-10% sampling for duplicates and debug messages - Reduce PlayerOrchestrator trace buffer from 200 to 50 entries - Remove high-frequency onTimeUpdate silly logs - Implement stricter production log levels (WARN+ for renderer, ERROR+ for main process) This should significantly reduce memory pressure from logger-related objects as identified in Chrome DevTools memory analysis. --- .../src/pages/player/engine/MediaClock.ts | 34 +-- .../pages/player/engine/PlayerOrchestrator.ts | 7 +- src/renderer/src/services/Logger.ts | 208 ++++++++++++------ 3 files changed, 165 insertions(+), 84 deletions(-) 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..256dc9f1 100644 --- a/src/renderer/src/pages/player/engine/PlayerOrchestrator.ts +++ b/src/renderer/src/pages/player/engine/PlayerOrchestrator.ts @@ -572,7 +572,8 @@ export class PlayerOrchestrator { onTimeUpdate(currentTime: number): void { this.mediaClock.updateTime(currentTime) - logger.silly('onTimeUpdate', { currentTime }) + // 移除高频的 silly 日志,减少内存压力 + // logger.silly('onTimeUpdate', { currentTime }) } onPlay(): void { @@ -1214,9 +1215,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/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} 条记录` + ) + } + /** * 导出日志 */ From 1b270b3f1f822fec275b80e5509d29994d7b7ce6 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Sat, 13 Sep 2025 15:10:53 +0800 Subject: [PATCH 04/12] fix(subtitle): resolve overlay pause/seek update delays with immediate state sync (#153) * fix(subtitle): resolve overlay pause/seek update delays with immediate state sync - Add immediate store updates in PlayerOrchestrator during seek/jumpToCue - Implement UserSeeking state in PlayerSettingsSaver to prevent conflicts - Ensure subtitle overlay UI responds instantly to user interactions - Maintain data consistency during user-initiated time changes * Update src/renderer/src/pages/player/engine/PlayerOrchestrator.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Update src/renderer/src/services/PlayerSettingsSaver.ts Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * fix(player): resolve seeking event mismatch causing duplicate subtitle jumps - Add missing mediaClock.startSeeking() call in PlayerOrchestrator.onSeeking() - Fix SeekEventCoordinator state inconsistency between start/end seeking events - Eliminate 'Ignoring seeked without active seeking' warnings in console - Ensure proper event sequence for both paused and playing states - All 66 player engine tests pass with TypeScript and lint validation Resolves issue where users needed to press subtitle buttons twice in paused state. * refactor(player): Use destroyOnHidden instead of destoryOnClose. * fix(player): prevent SubtitleSyncStrategy from overriding user subtitle jumps - Lock subtitle state machine for 2 seconds after user subtitle jump - Prevent SubtitleSyncStrategy from immediately overriding user selection - Add automatic unlock mechanism to restore normal subtitle sync behavior - Ensure 'goToNextSubtitle' respects user intent in paused state - Fix seeking event coordination in PlayerOrchestrator.onSeeking() Resolves the core issue where clicking to jump to a subtitle would be immediately overridden by the subtitle sync strategy, requiring users to click twice to achieve their intended jump. * fix(subtitle): eliminate subtitle content flickering during jumps - Add activeCueIndex to PlayerState and StateUpdater interface - Sync PlayerOrchestrator's activeCueIndex to store on updates - Modify useSubtitleEngine to prioritize store's activeCueIndex over time-based calculation - Ensure SubtitleContent component uses authoritative subtitle index - Fix initial activeCueIndex synchronization on StateUpdater connection Resolves flickering where subtitle content would briefly show wrong subtitle before displaying the correct one during user-initiated subtitle jumps. * fix(subtitle): eliminate subtitle overlay flickering during jumps - Add smart tolerance mechanism to useSubtitleOverlay shouldShow calculation - Display overlay when currentTime is within 2 seconds of subtitle start time - Handles user jump delays while preserving smooth normal playback behavior - Fixes flickering issue where overlay would briefly hide during subtitle navigation Closes final flickering issue in subtitle navigation system. * fix(subtitle): resolve overlay flickering through index priority and data stabilization - Add stable subtitle data memoization in useSubtitleOverlay to prevent unnecessary re-renders - Prioritize currentIndex over time-based checks in shouldShow calculation - Reorder PlayerOrchestrator lock sequence to ensure SubtitleLockFSM is active during updateContext - Implement granular dependency tracking for subtitle content changes --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../player/components/VideoErrorRecovery.tsx | 2 +- .../pages/player/engine/PlayerOrchestrator.ts | 58 ++++++++++++++-- .../engine/intent/IntentStrategyManager.ts | 1 - .../src/pages/player/hooks/usePlayerEngine.ts | 4 ++ .../pages/player/hooks/useSubtitleEngine.ts | 10 ++- .../pages/player/hooks/useSubtitleOverlay.ts | 62 +++++++++++++---- .../src/services/PlayerSettingsLoader.ts | 2 + .../src/services/PlayerSettingsSaver.ts | 69 +++++++++++++++++++ src/renderer/src/state/stores/player.store.ts | 4 ++ 9 files changed, 190 insertions(+), 22 deletions(-) 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/PlayerOrchestrator.ts b/src/renderer/src/pages/player/engine/PlayerOrchestrator.ts index 256dc9f1..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)) @@ -590,6 +639,7 @@ export class PlayerOrchestrator { } onSeeking(): void { + this.mediaClock.startSeeking() this.stateUpdater?.setSeeking?.(true) } @@ -662,8 +712,6 @@ export class PlayerOrchestrator { * 清理未发布的意图、重置字幕锁定状态、重载所有策略 */ private resetOnUserSeek(): void { - logger.debug('用户跳转,重置播放器状态') - // 清理未发布的意图 if (this.currentIntents.length > 0) { logger.debug(`清理 ${this.currentIntents.length} 个未发布的意图`) @@ -683,7 +731,7 @@ export class PlayerOrchestrator { // 重载策略管理器 this.strategyManager.reload(currentStrategies) - logger.debug('播放器状态重置完成') + logger.debug('用户跳转,播放器状态重置完成') } private registerBuiltinStrategies(): void { 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/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/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)), // 常用速度控制 From 561a3f7c102c6ba78db29e43790f407a83d96c9c Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Sat, 13 Sep 2025 15:53:17 +0800 Subject: [PATCH 05/12] feat(ffmpeg): implement dynamic FFmpeg download system with runtime management (#155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ffmpeg): implement dynamic FFmpeg download system with runtime management - Remove static FFmpeg bundling from build configuration - Add FFmpegDownloadService for cross-platform binary management - Implement automatic platform detection (Windows/macOS/Linux, x64/ARM64) - Add IPC channels for download progress monitoring and control - Integrate download service with existing FFmpegService architecture - Update build scripts to remove prebuild FFmpeg requirements - Add comprehensive test coverage for download functionality Changes: - electron-builder.yml: Remove extraResources FFmpeg bundling - package.json: Remove prebuild FFmpeg download from release scripts - FFmpegDownloadService.ts: New service with download/extract/management capabilities - FFmpegService.ts: Enhanced with download service integration and fallback logic - IpcChannel.ts: Add 9 new channels for download operations - ipc.ts: Register download service handlers for renderer communication - preload/index.ts: Expose download APIs to renderer process - useVideoFileSelect.ts: Updated to work with dynamic FFmpeg detection This implementation enables on-demand FFmpeg installation, reducing app bundle size by ~200MB while maintaining cross-platform compatibility and user experience. The system gracefully falls back to bundled → downloaded → system FFmpeg. * feat(settings): implement FFmpeg settings UI with download management - Add new FFmpegSettings component with status indicator and download controls - Remove deprecated FFmpeg build plugin from electron.vite.config.ts - Enhance IndicatorLight component with proper CSS-in-JS animation syntax - Add comprehensive i18n support for FFmpeg management (en-us, zh-cn) - Remove box-shadow from ant-btn components for cleaner UI appearance - Integrate FFmpeg settings into main SettingsPage navigation Changes: - FFmpegSettings.tsx: Complete UI implementation with download progress, path validation, and status management - electron.vite.config.ts: Remove build-time FFmpeg download plugin (shift to runtime approach) - IndicatorLight.tsx: Fix styled-components animation with proper css helper - i18n locales: Add 61 new translation keys for FFmpeg settings UI - ant.scss: Remove button shadows for consistent design system - SettingsPage.tsx: Add FFmpeg settings tab integration This implements the frontend interface for the dynamic FFmpeg download system, providing users with a comprehensive management UI for FFmpeg installation, status monitoring, and path configuration. * feat(ffmpeg): implement FFmpeg guidance dialog for enhanced user experience Add comprehensive FFmpeg download guidance system that transforms technical errors into user-friendly guidance with seamless navigation to settings and auto-download. **Components Added:** - FFmpegDownloadPrompt: Full-featured guidance dialog with benefits, effort info, and actions - Comprehensive internationalization support (zh-CN, en-US) **Hook Enhancements:** - useVideoFileSelect: Extended with FFmpeg prompt state management - Replaced technical error throwing with guided dialog display - Enhanced error detection for FFmpeg missing scenarios **Integration Updates:** - HomePage: State management for prompt visibility and component integration - EmptyState/VideoAddButton: Bidirectional state communication with parent - HeaderNavbar: Props forwarding for prompt handler **Features:** - Benefits explanation (compatibility, performance, reliability) - Installation effort communication - Auto-navigation to settings with download trigger - Seamless integration with existing video file selection workflow - Graceful error handling with user-centric messaging **Technical Details:** - Styled-components with theme variables and CSS custom properties - Modal-based UI with responsive design and accessibility - State management across component hierarchy - URL parameter-based auto-download triggering - Comprehensive TypeScript interfaces Transforms "视频处理组件未安装" technical errors into guided user experience that educates users about FFmpeg benefits and provides immediate resolution path. * test: fix FFmpegService mock for dynamic download system - Add getDownloadService method to FFmpegService mock - Include all FFmpegDownloadService interface methods in mock - Fix 43 failing test cases caused by missing mock method - All 554 test cases now pass successfully Resolves test failures introduced by FFmpeg dynamic download system implementation. * test: fix cross-platform FFmpeg executable name test - Update test to handle platform-specific executable names (ffmpeg vs ffmpeg.exe) - Fix Windows CI test failure where test expected 'ffmpeg' but got 'ffmpeg.exe' - Test now correctly validates system FFmpeg fallback behavior on all platforms - Maintains test coverage while supporting cross-platform compatibility Resolves Windows CI test failure in FFmpegService integration tests. --- electron-builder.yml | 5 - electron.vite.config.ts | 110 ---- package.json | 12 +- packages/shared/IpcChannel.ts | 12 + src/main/__tests__/ipc.database.test.ts | 12 +- src/main/ipc.ts | 45 ++ src/main/services/FFmpegDownloadService.ts | 591 +++++++++++++++++ src/main/services/FFmpegService.ts | 115 +++- .../__tests__/FFmpegDownloadService.test.ts | 241 +++++++ .../FFmpegService.integration.test.ts | 234 +++++++ src/preload/index.ts | 35 +- src/renderer/src/assets/styles/ant.scss | 4 + .../src/components/FFmpegDownloadPrompt.tsx | 286 ++++++++ .../src/components/IndicatorLight.tsx | 8 +- src/renderer/src/hooks/useVideoFileSelect.ts | 31 +- src/renderer/src/i18n/locales/en-us.json | 86 ++- src/renderer/src/i18n/locales/zh-cn.json | 272 +++++--- src/renderer/src/pages/home/EmptyState.tsx | 25 +- src/renderer/src/pages/home/HeaderNavbar.tsx | 9 +- src/renderer/src/pages/home/HomePage.tsx | 19 +- .../src/pages/home/VideoAddButton.tsx | 22 +- .../src/pages/settings/FFmpegSettings.tsx | 619 ++++++++++++++++++ .../src/pages/settings/SettingsPage.tsx | 10 +- 23 files changed, 2560 insertions(+), 243 deletions(-) create mode 100644 src/main/services/FFmpegDownloadService.ts create mode 100644 src/main/services/__tests__/FFmpegDownloadService.test.ts create mode 100644 src/main/services/__tests__/FFmpegService.integration.test.ts create mode 100644 src/renderer/src/components/FFmpegDownloadPrompt.tsx create mode 100644 src/renderer/src/pages/settings/FFmpegSettings.tsx 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 10fd1524..4477afe4 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 { } /> } /> } /> + } /> } /> From bc9a93825471be7e7e5d2850d7cbf48982c19857 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Sat, 13 Sep 2025 19:06:27 +0800 Subject: [PATCH 06/12] feat: add Windows ARM64 architecture support (#157) - Add arm64 architecture support for Windows NSIS installer target - Add arm64 architecture support for Windows Portable target - Align Windows build targets with existing macOS and Linux ARM64 support - Enable native ARM64 builds for Windows on ARM devices This change allows the application to run natively on Windows ARM64 devices, providing better performance and compatibility for users with ARM-based Windows machines. --- electron-builder.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/electron-builder.yml b/electron-builder.yml index 294dbf73..d44b266b 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -55,9 +55,11 @@ win: - target: nsis arch: - x64 + - arm64 - target: portable arch: - x64 + - arm64 signtoolOptions: sign: scripts/win-sign.js verifyUpdateCodeSignature: false From 7bb07e96bd9b8624f94d1d92062e163ce9d6a1a8 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Sat, 13 Sep 2025 19:49:48 +0800 Subject: [PATCH 07/12] fix(ui): use system title bar for Windows and Linux platforms (#158) - Configure Windows and Linux to use native system title bars instead of custom titleBarOverlay - Remove excessive padding-right in Navbar component (140px for Windows, 120px for Linux) - Maintain custom title bar for macOS with traffic light buttons integration - Update ThemeService to only apply titleBarOverlay changes to macOS windows - Simplify Navbar styling to use consistent 12px padding across all platforms This change eliminates the spacing issue in the Windows header area and provides a more native user experience on Windows and Linux platforms. Fixes the header spacing issue reported for Windows platform. --- src/main/services/ThemeService.ts | 4 +++- src/main/services/WindowService.ts | 24 +++++++++++++++++++--- src/renderer/src/components/app/Navbar.tsx | 3 +-- 3 files changed, 25 insertions(+), 6 deletions(-) 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/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` From c48399d1b66601fc55a4cf30918fc6e6c0956a3c Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Sat, 13 Sep 2025 22:27:48 +0800 Subject: [PATCH 08/12] refactor: remove legacy manual release scripts in favor of semantic-release (#160) - Remove pre-release-check.ts script with version validation and Git status checks - Remove release.ts script with interactive version selection and build automation - Remove rename-artifacts.ts script with platform-specific file renaming logic - Remove version-manager.ts script with semver version bumping functionality - Clean up package.json scripts: remove version:*, release:*, and related commands - Retain semantic-release configuration and migrate:* database commands - Transition from custom release tooling to standardized semantic-release workflow The removed scripts provided manual release management including: - Interactive version type selection (patch/minor/major/prerelease/beta) - Git status validation and commit automation - Cross-platform artifact renaming (Windows/macOS/Linux) - Build orchestration and publishing workflows This refactoring simplifies the release process by adopting semantic-release as the single source of truth for version management and automated publishing, reducing maintenance overhead and improving consistency with modern CI/CD practices. --- package.json | 15 -- scripts/pre-release-check.ts | 184 ------------- scripts/release.ts | 226 ---------------- scripts/rename-artifacts.ts | 499 ----------------------------------- scripts/version-manager.ts | 231 ---------------- 5 files changed, 1155 deletions(-) delete mode 100644 scripts/pre-release-check.ts delete mode 100644 scripts/release.ts delete mode 100644 scripts/rename-artifacts.ts delete mode 100644 scripts/version-manager.ts diff --git a/package.json b/package.json index 4477afe4..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 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", "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", 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/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 -} From c59a1d527cd121f918cf707d1a91532907380404 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Sat, 13 Sep 2025 22:55:06 +0800 Subject: [PATCH 09/12] fix(updater): resolve pre-release version detection issue (#161) - Replace find() with filter() + sort() to get the latest matching release - Increase API request limit from 8 to 20 releases for better coverage - Add proper sorting by published_at timestamp to ensure latest version - Enhance logging for better debugging of version detection process Fixes issue where alpha.10 was not detected due to find() returning the first match instead of the latest published version. --- src/main/services/AppUpdater.ts | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/main/services/AppUpdater.ts b/src/main/services/AppUpdater.ts index 879e3b44..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 From 8a62a04e27372f366979380f907ddc587725ed79 Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Sat, 13 Sep 2025 23:03:34 +0800 Subject: [PATCH 10/12] fix(player): persist relocated video file path to database (#162) When users relocate a missing video file through the error recovery dialog, the new file path is now properly saved to the database instead of only updating local state. This ensures the relocated path persists across app sessions, eliminating the need to relocate files repeatedly. Changes: - Update handleFileRelocate to fetch video record and file ID - Call db.files.updateFile() to persist new path to database - Maintain existing local state updates for immediate UI response - Add comprehensive error handling and logging Fixes issue where video file relocation was temporary and required repeated user action on subsequent app launches. --- src/renderer/src/pages/player/PlayerPage.tsx | 26 +++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) 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] From 71bded46348acfb6c0332a878b806605b85e22af Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Sun, 14 Sep 2025 09:56:02 +0800 Subject: [PATCH 11/12] feat(ffmpeg): add China mirror support for FFmpeg downloads (#164) * feat(ffmpeg): add China mirror support for FFmpeg downloads - Add IP-based region detection using ipinfo.io API - Support China mainland, Hong Kong, Macau, Taiwan regions - Add dedicated China mirror URLs from gitcode.com - Implement automatic fallback from China to global mirrors - Add comprehensive test coverage for new functionality - Default to China mirror on detection failure for better UX Breaking change: Service now defaults to China mirror for better performance in Chinese regions * fix(test): remove unused parameter in FFmpegDownloadService test - Fix TypeScript error TS6133 for unused 'url' parameter - Replace unused 'url' with underscore in mock implementation --- src/main/services/FFmpegDownloadService.ts | 232 ++++++++++++++-- .../__tests__/FFmpegDownloadService.test.ts | 250 ++++++++++++++++++ 2 files changed, 467 insertions(+), 15 deletions(-) diff --git a/src/main/services/FFmpegDownloadService.ts b/src/main/services/FFmpegDownloadService.ts index dd85fcbf..b9e02729 100644 --- a/src/main/services/FFmpegDownloadService.ts +++ b/src/main/services/FFmpegDownloadService.ts @@ -103,29 +103,139 @@ const FFMPEG_VERSIONS: Record> = { } } -// 镜像源配置 - 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: '' -// } -// } +// 中国区专供的 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' } /** @@ -167,6 +277,16 @@ export class FFmpegDownloadService { 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 } @@ -175,11 +295,31 @@ export class FFmpegDownloadService { */ public getAllSupportedVersions(): FFmpegVersion[] { const versions: FFmpegVersion[] = [] - for (const platformConfigs of Object.values(FFMPEG_VERSIONS)) { + + // 添加当前镜像源的版本 + 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 } @@ -205,14 +345,76 @@ export class FFmpegDownloadService { 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 }) + 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) diff --git a/src/main/services/__tests__/FFmpegDownloadService.test.ts b/src/main/services/__tests__/FFmpegDownloadService.test.ts index 0c5c6e6e..2d3243f7 100644 --- a/src/main/services/__tests__/FFmpegDownloadService.test.ts +++ b/src/main/services/__tests__/FFmpegDownloadService.test.ts @@ -25,6 +25,9 @@ vi.mock('../LoggerService', () => ({ 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' @@ -101,6 +104,9 @@ describe('FFmpegDownloadService', () => { 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', @@ -144,6 +150,29 @@ describe('FFmpegDownloadService', () => { 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') + }) }) }) @@ -238,4 +267,225 @@ describe('FFmpegDownloadService', () => { 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') + }) + }) }) From bdf66209807395eed65d86bb2db220f0eac5b49d Mon Sep 17 00:00:00 2001 From: mkdir700 Date: Sun, 14 Sep 2025 10:13:34 +0800 Subject: [PATCH 12/12] ci: Sync release to gitcode --- .github/workflows/sync-release-to-gitcode.yml | 447 ++++++++++++++++++ scripts/upload-assets.js | 426 +++++++++++++++++ 2 files changed, 873 insertions(+) create mode 100644 .github/workflows/sync-release-to-gitcode.yml create mode 100644 scripts/upload-assets.js 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/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