((resolve, reject) => {
- const downloadScript = spawn(
- 'tsx',
- ['scripts/download-ffmpeg.ts', 'platform', targetPlatform, targetArch],
- {
- stdio: 'inherit'
+ // 在不同环境中使用不同的命令来确保兼容性
+ 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) {
@@ -48,7 +76,8 @@ function ffmpegDownloadPlugin() {
})
})
} catch (error) {
- console.warn('FFmpeg Download failed', error)
+ console.error('FFmpeg Download failed:', error)
+ throw new Error(`Failed to download FFmpeg for ${targetPlatform}-${targetArch}: ${error}`)
}
}
}
diff --git a/package.json b/package.json
index 92a27f60..ff4da17c 100644
--- a/package.json
+++ b/package.json
@@ -20,11 +20,12 @@
"start": "electron-vite preview",
"dev": "electron-vite dev",
"build": "npm run typecheck && electron-vite build",
+ "build:release": "npm run typecheck && electron-vite build",
"postinstall": "electron-builder install-app-deps",
"build:unpack": "npm run build && electron-builder --dir --publish never",
- "build:win": "npm run build && electron-builder --win --publish never",
- "build:win:x64": "npm run build && electron-builder --win --x64 --publish never",
- "build:win:arm64": "npm run build && electron-builder --win --arm64 --publish never",
+ "build:win": "cross-env BUILD_TARGET_PLATFORM=win32 npm run build && electron-builder --win --publish never",
+ "build:win:x64": "cross-env BUILD_TARGET_PLATFORM=win32 BUILD_TARGET_ARCH=x64 npm run build && electron-builder --win --x64 --publish never",
+ "build:win:arm64": "cross-env BUILD_TARGET_PLATFORM=win32 BUILD_TARGET_ARCH=arm64 npm run build && electron-builder --win --arm64 --publish never",
"build:mac": "electron-vite build && electron-builder --mac --publish never",
"build:mac:x64": "npm run build && electron-builder --mac --x64 --publish never",
"build:mac:arm64": "npm run build && electron-builder --mac --arm64 --publish never",
@@ -47,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 build && electron-builder --publish onTagOrDraft",
- "release:all": "npm run build && electron-builder --publish always",
- "release:never": "npm run build && electron-builder --publish never",
- "release:draft": "npm run build && electron-builder --publish onTagOrDraft",
+ "release": "npm run ffmpeg:download-all && npm run build:release && electron-builder --publish onTagOrDraft",
+ "release:all": "npm run ffmpeg:download-all && npm run build:release && electron-builder --publish always",
+ "release:never": "npm run ffmpeg:download-all && npm run build:release && electron-builder --publish never",
+ "release:draft": "npm run ffmpeg:download-all && npm run build:release && electron-builder --publish onTagOrDraft",
"migrate": "tsx src/main/db/migration-cli.ts",
"migrate:up": "npm run migrate up",
"migrate:down": "npm run migrate down",
@@ -71,7 +72,8 @@
"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": "npm run ffmpeg:download",
+ "prebuild:release": "echo 'FFmpeg already downloaded by release script'"
},
"dependencies": {
"@ant-design/icons": "^6.0.1",
@@ -128,6 +130,7 @@
"@welldone-software/why-did-you-render": "^10.0.1",
"cli-progress": "^3.12.0",
"code-inspector-plugin": "^1.2.7",
+ "cross-env": "^10.0.0",
"electron": "37.2.4",
"electron-builder": "26.0.19",
"electron-devtools-installer": "^4.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index eac64848..bed42240 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -171,6 +171,9 @@ importers:
code-inspector-plugin:
specifier: ^1.2.7
version: 1.2.7
+ cross-env:
+ specifier: ^10.0.0
+ version: 10.0.0
electron:
specifier: 37.2.4
version: 37.2.4
@@ -685,6 +688,9 @@ packages:
'@emotion/unitless@0.8.1':
resolution: {integrity: sha512-KOEGMu6dmJZtpadb476IsZBclKvILjopjUii3V+7MnXIQCYh8W3NgNcgwo21n9LXZX6EDIKvqfjYxXebDwxKmQ==}
+ '@epic-web/invariant@1.0.0':
+ resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
+
'@esbuild/aix-ppc64@0.25.8':
resolution: {integrity: sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==}
engines: {node: '>=18'}
@@ -2584,6 +2590,11 @@ packages:
cross-dirname@0.1.0:
resolution: {integrity: sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==}
+ cross-env@10.0.0:
+ resolution: {integrity: sha512-aU8qlEK/nHYtVuN4p7UQgAwVljzMg8hB4YK5ThRqD2l/ziSnryncPNn7bMLt5cFYsKVKBh8HqLqyCoTupEUu7Q==}
+ engines: {node: '>=20'}
+ hasBin: true
+
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -7433,6 +7444,8 @@ snapshots:
'@emotion/unitless@0.8.1': {}
+ '@epic-web/invariant@1.0.0': {}
+
'@esbuild/aix-ppc64@0.25.8':
optional: true
@@ -9622,6 +9635,11 @@ snapshots:
cross-dirname@0.1.0:
optional: true
+ cross-env@10.0.0:
+ dependencies:
+ '@epic-web/invariant': 1.0.0
+ cross-spawn: 7.0.6
+
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
diff --git a/scripts/download-ffmpeg.ts b/scripts/download-ffmpeg.ts
index f4017661..442d5034 100644
--- a/scripts/download-ffmpeg.ts
+++ b/scripts/download-ffmpeg.ts
@@ -376,11 +376,28 @@ class FFmpegDownloader {
// CLI 入口
async function main() {
const args = process.argv.slice(2)
- const command = args[0] || 'current'
+ let command = args[0]
const downloader = new FFmpegDownloader()
try {
+ // 优先检查环境变量,如果设置了构建目标则使用目标平台
+ if (process.env.BUILD_TARGET_PLATFORM) {
+ console.log(
+ `检测到构建目标平台: ${process.env.BUILD_TARGET_PLATFORM}-${process.env.BUILD_TARGET_ARCH || process.arch}`
+ )
+ await downloader.downloadFFmpeg(
+ process.env.BUILD_TARGET_PLATFORM,
+ process.env.BUILD_TARGET_ARCH || process.arch
+ )
+ return
+ }
+
+ // 如果没有环境变量,按原逻辑处理命令参数
+ if (!command) {
+ command = 'current'
+ }
+
switch (command) {
case 'all':
await downloader.downloadAllPlatforms()
@@ -416,6 +433,10 @@ async function main() {
win32: x64, arm64
darwin: x64, arm64
linux: x64, arm64
+
+环境变量:
+ BUILD_TARGET_PLATFORM - 构建目标平台 (win32, darwin, linux)
+ BUILD_TARGET_ARCH - 构建目标架构 (x64, arm64)
`)
}
} catch (error) {
diff --git a/src/main/__tests__/DictionaryService.test.ts b/src/main/__tests__/DictionaryService.test.ts
new file mode 100644
index 00000000..b7cbed82
--- /dev/null
+++ b/src/main/__tests__/DictionaryService.test.ts
@@ -0,0 +1,485 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+// Mock logger service - 简化版本,专注于核心功能测试
+vi.mock('@logger', () => ({
+ loggerService: {
+ withContext: vi.fn(() => ({
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn()
+ }))
+ }
+}))
+
+import DictionaryService from '../services/DictionaryService'
+
+// Mock fetch globally
+const mockFetch = vi.fn()
+global.fetch = mockFetch as any
+
+describe('DictionaryService', () => {
+ let dictionaryService: DictionaryService
+ const mockEvent = {} as Electron.IpcMainInvokeEvent
+
+ beforeEach(() => {
+ vi.clearAllMocks()
+ dictionaryService = new DictionaryService()
+ })
+
+ describe('queryEudic - 核心功能测试', () => {
+ describe('✅ 成功场景', () => {
+ it('应该成功查询单词并返回完整数据 - hello 示例', async () => {
+ // 模拟欧陆词典 hello 的真实 HTML 响应
+ const mockHtmlResponse = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - int. 喂;哈罗
+ - n. 表示问候, 惊奇或唤起注意时的用语
+
+
+
+
+
Hello, how are you?
+
你好,你好吗?
+
+
+
+ `
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(mockHtmlResponse)
+ })
+
+ const result = await dictionaryService.queryEudic(mockEvent, 'hello')
+
+ // 验证返回结果
+ expect(result.success).toBe(true)
+ expect(result.data).toBeDefined()
+ expect(result.data!.word).toBe('hello')
+ expect(result.data!.phonetic).toBe("/hə'ləʊ/")
+ expect(result.data!.definitions).toHaveLength(2)
+ expect(result.data!.definitions[0]).toEqual({
+ partOfSpeech: 'int.',
+ meaning: '喂;哈罗'
+ })
+ expect(result.data!.definitions[1]).toEqual({
+ partOfSpeech: 'n.',
+ meaning: '表示问候, 惊奇或唤起注意时的用语'
+ })
+
+ // 验证API调用
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://dict.eudic.net/dicts/MiniDictSearch2?word=hello&context=hello',
+ expect.objectContaining({
+ method: 'GET',
+ headers: expect.objectContaining({
+ 'User-Agent': expect.stringContaining('Mozilla')
+ })
+ })
+ )
+ })
+
+ it('应该成功解析简单格式的词典响应 - program 示例', async () => {
+ const mockSimpleHtmlResponse = `
+
+
+ /ˈproʊɡræm/
+
+ n. 程序,节目
+
+
+
+ `
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(mockSimpleHtmlResponse)
+ })
+
+ const result = await dictionaryService.queryEudic(mockEvent, 'program', 'computer program')
+
+ expect(result.success).toBe(true)
+ expect(result.data!.word).toBe('program')
+ expect(result.data!.phonetic).toBe('/ˈproʊɡræm/')
+ expect(result.data!.definitions).toHaveLength(1)
+ expect(result.data!.definitions[0]).toEqual({
+ partOfSpeech: 'n.',
+ meaning: '程序,节目'
+ })
+
+ // 验证URL编码处理
+ expect(mockFetch).toHaveBeenCalledWith(
+ 'https://dict.eudic.net/dicts/MiniDictSearch2?word=program&context=computer+program',
+ expect.any(Object)
+ )
+ })
+
+ it('应该使用备用解析策略处理复杂HTML结构', async () => {
+ const mockComplexHtmlResponse = `
+
+
+ /test/
+
+
+ - v. 测试,检验
+ - n. 测试,试验
+ - adj. 测试的
+
+
+
+
+ `
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(mockComplexHtmlResponse)
+ })
+
+ const result = await dictionaryService.queryEudic(mockEvent, 'test')
+
+ expect(result.success).toBe(true)
+ expect(result.data!.definitions).toHaveLength(3)
+ expect(result.data!.definitions[0].partOfSpeech).toBe('v.')
+ expect(result.data!.definitions[0].meaning).toBe('测试,检验')
+ })
+
+ it('应该正确解析例句和翻译', async () => {
+ const mockHtmlWithExamplesAndTranslations = `
+
+
+
v. 学习
+
+ I learn English every day.
+ Learning is fun.
+ 我每天学习英语。
+ 学习很有趣。
+
+ `
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(mockHtmlWithExamplesAndTranslations)
+ })
+
+ const result = await dictionaryService.queryEudic(mockEvent, 'learn')
+
+ expect(result.success).toBe(true)
+ expect(result.data!.examples).toEqual(['I learn English every day.', 'Learning is fun.'])
+ expect(result.data!.translations).toEqual(['我每天学习英语。', '学习很有趣。'])
+ })
+ })
+
+ describe('❌ 错误处理 - 健壮性测试', () => {
+ it('应该处理HTTP错误', async () => {
+ mockFetch.mockResolvedValue({
+ ok: false,
+ status: 404,
+ statusText: 'Not Found'
+ })
+
+ const result = await dictionaryService.queryEudic(mockEvent, 'nonexistent')
+
+ expect(result.success).toBe(false)
+ expect(result.error).toBe('HTTP 404: Not Found')
+ expect(result.data).toBeUndefined()
+ })
+
+ it('应该处理网络错误', async () => {
+ const networkError = new Error('Network connection failed')
+ mockFetch.mockRejectedValue(networkError)
+
+ const result = await dictionaryService.queryEudic(mockEvent, 'hello')
+
+ expect(result.success).toBe(false)
+ expect(result.error).toBe('Network connection failed')
+ })
+
+ it('应该处理非Error类型的异常', async () => {
+ mockFetch.mockRejectedValue('String error')
+
+ const result = await dictionaryService.queryEudic(mockEvent, 'hello')
+
+ expect(result.success).toBe(false)
+ expect(result.error).toBe('网络错误')
+ })
+
+ it('应该处理无法解析出释义的情况', async () => {
+ const mockEmptyHtmlResponse = `
+
+
+ No useful content here
+
+
+ `
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(mockEmptyHtmlResponse)
+ })
+
+ const result = await dictionaryService.queryEudic(mockEvent, 'unknown')
+
+ expect(result.success).toBe(false)
+ expect(result.error).toBe('未能从HTML中解析出任何释义')
+ })
+ })
+
+ describe('🛡️ HTML解析健壮性', () => {
+ it('应该处理带有特殊字符的内容', async () => {
+ const mockSpecialCharHtml = `
+
+
+ /ˈspɛʃəl/
+
+
+ - adj. 特殊的;特别的;"专门的
+
+
+
+
+ `
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(mockSpecialCharHtml)
+ })
+
+ const result = await dictionaryService.queryEudic(mockEvent, 'special')
+
+ expect(result.success).toBe(true)
+ expect(result.data!.definitions[0].meaning).toContain('特殊的;特别的;"专门的')
+ })
+
+ it('应该正确处理音标格式变化', async () => {
+ const mockPhoneticVariations = `
+
+
+ UK /juːˈnaɪtɪd/
+
+
adj. 联合的,统一的
+
+
+
+ `
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(mockPhoneticVariations)
+ })
+
+ const result = await dictionaryService.queryEudic(mockEvent, 'united')
+
+ expect(result.success).toBe(true)
+ expect(result.data!.phonetic).toBe('UK /juːˈnaɪtɪd/')
+ })
+
+ it('应该限制备用解析策略的结果数量', async () => {
+ const mockManyItemsHtml = `
+
+
+
+ - 第一个中文释义
+ - 第二个中文释义
+ - 第三个中文释义
+ - 第四个中文释义
+ - 第五个中文释义
+ - 第六个中文释义
+ - 第七个中文释义
+
+
+
+ `
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(mockManyItemsHtml)
+ })
+
+ const result = await dictionaryService.queryEudic(mockEvent, 'many')
+
+ expect(result.success).toBe(true)
+ expect(result.data!.definitions.length).toBe(5) // 应该被限制为最多5个
+ })
+
+ it('应该忽略过长或过短的无关内容', async () => {
+ const mockNoisyHtml = `
+
+
+
+ - a
+ - 这是一个正常长度的中文释义
+ - ${'很'.repeat(250)}
+ - 另一个正常的释义
+
+
+
+ `
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(mockNoisyHtml)
+ })
+
+ const result = await dictionaryService.queryEudic(mockEvent, 'noisy')
+
+ expect(result.success).toBe(true)
+ expect(result.data!.definitions.length).toBe(2)
+ expect(
+ result.data!.definitions.every(
+ (def) => def.meaning.length >= 3 && def.meaning.length < 200
+ )
+ ).toBe(true)
+ })
+ })
+
+ describe('⚙️ 参数处理', () => {
+ it('应该正确处理URL编码', async () => {
+ const mockHtmlResponse = `
+
+
+
测试内容
+
+
+ `
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(mockHtmlResponse)
+ })
+
+ await dictionaryService.queryEudic(mockEvent, 'hello world', 'test context')
+
+ const expectedUrl =
+ 'https://dict.eudic.net/dicts/MiniDictSearch2?word=hello+world&context=test+context'
+ expect(mockFetch).toHaveBeenCalledWith(expectedUrl, expect.any(Object))
+ })
+
+ it('应该在context为空时使用word作为默认context', async () => {
+ const mockHtmlResponse = `
+
+
+
测试内容
+
+
+ `
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(mockHtmlResponse)
+ })
+
+ await dictionaryService.queryEudic(mockEvent, 'test')
+
+ const expectedUrl = 'https://dict.eudic.net/dicts/MiniDictSearch2?word=test&context=test'
+ expect(mockFetch).toHaveBeenCalledWith(expectedUrl, expect.any(Object))
+ })
+ })
+
+ describe('🔄 边界情况和压力测试', () => {
+ it('应该处理空字符串查询', async () => {
+ const mockHtmlResponse = `Empty query
`
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(mockHtmlResponse)
+ })
+
+ const result = await dictionaryService.queryEudic(mockEvent, '')
+
+ expect(result.success).toBe(false)
+ expect(result.error).toBe('未能从HTML中解析出任何释义')
+ })
+
+ it('应该处理大量HTML内容', async () => {
+ const largeMockHtml = `
+
+ /lærdʒ/
+
+
adj. 大的
+
+ ${'无关内容
'.repeat(1000)}
+
+ `
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(largeMockHtml)
+ })
+
+ const result = await dictionaryService.queryEudic(mockEvent, 'large')
+
+ expect(result.success).toBe(true)
+ expect(result.data!.definitions[0].meaning).toBe('大的')
+ })
+
+ it('应该处理畸形HTML', async () => {
+ const malformedHtml = `
+
+ /test/
+
+
adj. 测试的
+
+
+ `
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(malformedHtml)
+ })
+
+ const result = await dictionaryService.queryEudic(mockEvent, 'malformed')
+
+ // 应该能处理畸形HTML而不抛出异常
+ expect(result.success).toBe(true)
+ })
+
+ it('应该处理没有例句和翻译的情况', async () => {
+ const mockHtmlWithoutExamplesAndTranslations = `
+
+
+
n. 单词
+
+
+ `
+
+ mockFetch.mockResolvedValue({
+ ok: true,
+ text: () => Promise.resolve(mockHtmlWithoutExamplesAndTranslations)
+ })
+
+ const result = await dictionaryService.queryEudic(mockEvent, 'word')
+
+ expect(result.success).toBe(true)
+ expect(result.data!.examples).toBeUndefined()
+ expect(result.data!.translations).toBeUndefined()
+ })
+ })
+ })
+})
diff --git a/src/main/services/TrayService.ts b/src/main/services/TrayService.ts
index 1d20f0e1..ba65280a 100644
--- a/src/main/services/TrayService.ts
+++ b/src/main/services/TrayService.ts
@@ -50,7 +50,7 @@ export class TrayService {
this.tray.setContextMenu(this.contextMenu)
}
- this.tray.setToolTip('Cherry Studio')
+ this.tray.setToolTip('EchoPlayer')
this.tray.on('right-click', () => {
if (this.contextMenu) {
diff --git a/src/main/utils/file.ts b/src/main/utils/file.ts
index 3c8ffaac..a08af60f 100644
--- a/src/main/utils/file.ts
+++ b/src/main/utils/file.ts
@@ -117,14 +117,14 @@ export function getFileExt(filePath: string): string {
}
/**
- * Returns the path to the CherryStudio temporary directory.
+ * Returns the path to the EchoPlayer temporary directory.
*
- * The path is constructed by joining the Electron system temporary directory with "CherryStudio".
+ * The path is constructed by joining the Electron system temporary directory with "EchoPlayer".
*
- * @returns The full filesystem path for CherryStudio's temporary directory.
+ * @returns The full filesystem path for EchoPlayer's temporary directory.
*/
export function getTempDir() {
- return path.join(app.getPath('temp'), 'CherryStudio')
+ return path.join(app.getPath('temp'), 'EchoPlayer')
}
export function getFilesDir() {
@@ -132,7 +132,7 @@ export function getFilesDir() {
}
export function getConfigDir() {
- return path.join(os.homedir(), '.cherrystudio', 'config')
+ return path.join(os.homedir(), '.echoplayer', 'config')
}
export function getCacheDir() {
@@ -144,7 +144,7 @@ export function getAppConfigDir(name: string) {
}
export function getMcpDir() {
- return path.join(os.homedir(), '.cherrystudio', 'mcp')
+ return path.join(os.homedir(), '.echoplayer', 'mcp')
}
/**
diff --git a/src/main/utils/init.ts b/src/main/utils/init.ts
index a691e427..e9eeb39c 100644
--- a/src/main/utils/init.ts
+++ b/src/main/utils/init.ts
@@ -17,7 +17,7 @@ function hasWritePermission(path: string) {
}
function getConfigDir() {
- return path.join(os.homedir(), '.cherrystudio', 'config')
+ return path.join(os.homedir(), '.echoplayer', 'config')
}
export function initAppDataDir() {
@@ -51,13 +51,13 @@ function getAppDataPathFromConfig() {
if (isLinux && process.env.APPIMAGE) {
// 如果是 AppImage 打包的应用,直接使用 APPIMAGE 环境变量
// 这样可以确保获取到正确的可执行文件路径
- executablePath = path.join(path.dirname(process.env.APPIMAGE), 'cherry-studio.appimage')
+ executablePath = path.join(path.dirname(process.env.APPIMAGE), 'EchoPlayer.appimage')
}
if (isWin && isPortable) {
executablePath = path.join(
process.env.PORTABLE_EXECUTABLE_DIR || '',
- 'cherry-studio-portable.exe'
+ 'EchoPlayer-portable.exe'
)
}
@@ -94,15 +94,12 @@ export function updateAppDataConfig(appDataPath: string) {
const configPath = path.join(configDir, 'config.json')
let executablePath = app.getPath('exe')
if (isLinux && process.env.APPIMAGE) {
- executablePath = path.join(path.dirname(process.env.APPIMAGE), 'cherry-studio.appimage')
+ executablePath = path.join(path.dirname(process.env.APPIMAGE), 'EchoPlayer.appimage')
}
// 如果是 Windows 可移植版本,则使用 PORTABLE_EXECUTABLE_FILE 环境变量
if (isWin && isPortable) {
- executablePath = path.join(
- process.env.PORTABLE_EXECUTABLE_DIR || '',
- 'cherry-studio-portable.exe'
- )
+ executablePath = path.join(process.env.PORTABLE_EXECUTABLE_DIR || '', 'EchoPlayer-portable.exe')
}
if (!fs.existsSync(configPath)) {
diff --git a/src/main/utils/systemInfo.ts b/src/main/utils/systemInfo.ts
index 4eaedeed..71cb3276 100644
--- a/src/main/utils/systemInfo.ts
+++ b/src/main/utils/systemInfo.ts
@@ -89,5 +89,5 @@ export function getSystemInfo(): SystemInfo {
export function generateUserAgent(): string {
const systemInfo = getSystemInfo()
- return `Mozilla/5.0 (${systemInfo.osString}; ${systemInfo.archString}) AppleWebKit/537.36 (KHTML, like Gecko) CherryStudio/${systemInfo.appVersion} Chrome/124.0.0.0 Safari/537.36`
+ return `Mozilla/5.0 (${systemInfo.osString}; ${systemInfo.archString}) AppleWebKit/537.36 (KHTML, like Gecko) EchoPlayer/${systemInfo.appVersion} Chrome/124.0.0.0 Safari/537.36`
}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index d929be23..d8b2cc20 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -2,7 +2,7 @@ import { electronAPI } from '@electron-toolkit/preload'
import { UpgradeChannel } from '@shared/config/constant'
import { LogLevel, LogSourceWithContext } from '@shared/config/logger'
import { IpcChannel } from '@shared/IpcChannel'
-import { FFmpegVideoInfo, Shortcut, ThemeMode } from '@types'
+import { DictionaryResponse, FFmpegVideoInfo, Shortcut, ThemeMode } from '@types'
import { contextBridge, ipcRenderer, OpenDialogOptions, shell, webUtils } from 'electron'
import type {
FileMetadata,
@@ -308,6 +308,12 @@ const api = {
shell.openExternal(url, options)
},
+ // 词典服务 API
+ dictionary: {
+ queryEudic: (word: string, context?: string): Promise =>
+ ipcRenderer.invoke(IpcChannel.Dictionary_Eudic, word, context)
+ },
+
// 数据库相关 API
db: {
// 文件 DAO
diff --git a/src/renderer/src/i18n/label.ts b/src/renderer/src/i18n/label.ts
index 217ee518..917e7ce1 100644
--- a/src/renderer/src/i18n/label.ts
+++ b/src/renderer/src/i18n/label.ts
@@ -179,7 +179,8 @@ const shortcutKeys = [
'subtitle_mode_none',
'subtitle_mode_original',
'subtitle_mode_translated',
- 'subtitle_mode_bilingual'
+ 'subtitle_mode_bilingual',
+ 'copy_subtitle'
] as const
const shortcutKeyMap = shortcutKeys.reduce(
diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json
index 378da448..42a7197b 100644
--- a/src/renderer/src/i18n/locales/zh-cn.json
+++ b/src/renderer/src/i18n/locales/zh-cn.json
@@ -106,6 +106,10 @@
"fullscreen": {
"enter": "全屏",
"exit": "退出全屏"
+ },
+ "copy": {
+ "success": "已复制",
+ "failed": "复制失败,无法访问剪贴板"
}
},
"subtitles": {
@@ -290,6 +294,7 @@
"clear_shortcut": "清除快捷键",
"clear_topic": "清空消息",
"copy_last_message": "复制上一条消息",
+ "copy_subtitle": "复制字幕",
"enabled": "启用",
"escape_fullscreen": "退出全屏",
"exit_fullscreen": "退出全屏",
diff --git a/src/renderer/src/infrastructure/constants/shortcuts.const.ts b/src/renderer/src/infrastructure/constants/shortcuts.const.ts
index a4efa9cb..a0024f38 100644
--- a/src/renderer/src/infrastructure/constants/shortcuts.const.ts
+++ b/src/renderer/src/infrastructure/constants/shortcuts.const.ts
@@ -130,5 +130,12 @@ export const DEFAULT_SHORTCUTS: Shortcut[] = [
editable: true,
enabled: true,
system: false
+ },
+ {
+ key: 'copy_subtitle',
+ shortcut: ['CommandOrControl', 'C'],
+ editable: true,
+ enabled: true,
+ system: false
}
]
diff --git a/src/renderer/src/pages/player/components/SubtitleContent.tsx b/src/renderer/src/pages/player/components/SubtitleContent.tsx
index 72a5be19..855dbc6f 100644
--- a/src/renderer/src/pages/player/components/SubtitleContent.tsx
+++ b/src/renderer/src/pages/player/components/SubtitleContent.tsx
@@ -235,26 +235,6 @@ export const SubtitleContent = memo(function SubtitleContent({
return undefined
}, [selectionState.startIndex, selectionState.endIndex, handleGlobalClick])
- // === 键盘复制支持 ===
- const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
- if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
- const selection = window.getSelection()
- const selectedText = selection?.toString()
-
- if (selectedText) {
- // 复制到剪贴板
- navigator.clipboard
- .writeText(selectedText)
- .then(() => {
- logger.info('字幕文本已复制到剪贴板', { length: selectedText.length })
- })
- .catch((error) => {
- logger.error('复制到剪贴板失败', { error })
- })
- }
- }
- }, [])
-
// === 渲染分词文本 ===
const renderTokenizedText = useCallback(
(tokens: WordToken[]) => {
@@ -366,8 +346,6 @@ export const SubtitleContent = memo(function SubtitleContent({
ref={containerRef}
className={className}
style={style}
- onKeyDown={handleKeyDown}
- tabIndex={0} // 使元素可聚焦,支持键盘操作
role="region"
data-testid="subtitle-content"
>
diff --git a/src/renderer/src/pages/player/components/SubtitleOverlay.tsx b/src/renderer/src/pages/player/components/SubtitleOverlay.tsx
index 12716cb3..73cd2e0b 100644
--- a/src/renderer/src/pages/player/components/SubtitleOverlay.tsx
+++ b/src/renderer/src/pages/player/components/SubtitleOverlay.tsx
@@ -11,10 +11,21 @@
*/
import { loggerService } from '@logger'
+import {
+ ANIMATION_DURATION,
+ BORDER_RADIUS,
+ EASING,
+ FONT_SIZES,
+ FONT_WEIGHTS,
+ GLASS_EFFECT,
+ SHADOWS,
+ SPACING,
+ Z_INDEX
+} from '@renderer/infrastructure/styles/theme'
import { usePlayerStore } from '@renderer/state'
import { SubtitleBackgroundType, SubtitleDisplayMode } from '@types'
import { Tooltip } from 'antd'
-import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'
+import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled, { css } from 'styled-components'
@@ -70,6 +81,37 @@ export const SubtitleOverlay = memo(function SubtitleOverlay({
// === 本地状态 ===
const overlayRef = useRef(null)
+ const [toastVisible, setToastVisible] = useState(false)
+ const [toastMessage, setToastMessage] = useState('')
+ const hideToastTimerRef = useRef | null>(null)
+
+ // === 复制成功toast监听器 ===
+ useEffect(() => {
+ const handleSubtitleCopied = (event: CustomEvent<{ message: string }>) => {
+ const { message } = event.detail
+ setToastMessage(message)
+ setToastVisible(true)
+
+ // 2秒后自动隐藏toast(防抖)
+ if (hideToastTimerRef.current) {
+ clearTimeout(hideToastTimerRef.current)
+ }
+ hideToastTimerRef.current = setTimeout(() => {
+ setToastVisible(false)
+ hideToastTimerRef.current = null
+ }, 800)
+ }
+
+ window.addEventListener('subtitle-copied', handleSubtitleCopied as EventListener)
+
+ return () => {
+ if (hideToastTimerRef.current) {
+ clearTimeout(hideToastTimerRef.current)
+ hideToastTimerRef.current = null
+ }
+ window.removeEventListener('subtitle-copied', handleSubtitleCopied as EventListener)
+ }
+ }, [])
// === 初始化和容器边界更新 ===
useEffect(() => {
@@ -434,6 +476,10 @@ export const SubtitleOverlay = memo(function SubtitleOverlay({
data-testid="subtitle-resize-handle"
/>
+
+
+ {toastMessage}
+
)
})
@@ -569,3 +615,28 @@ const ResizeHandle = styled.div<{ $visible: boolean }>`
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
}
`
+
+const ToastContainer = styled.div<{ $visible: boolean }>`
+ position: absolute;
+ top: -40px;
+ left: 50%;
+ transform: translateX(-50%);
+ opacity: ${(props) => (props.$visible ? 1 : 0)};
+ visibility: ${(props) => (props.$visible ? 'visible' : 'hidden')};
+ transition: opacity ${ANIMATION_DURATION.SLOW} ${EASING.APPLE};
+ z-index: ${Z_INDEX.MODAL};
+ pointer-events: none;
+`
+
+const ToastContent = styled.div`
+ background: rgba(0, 0, 0, ${GLASS_EFFECT.BACKGROUND_ALPHA.LIGHT});
+ color: #ffffff;
+ padding: ${SPACING.XS}px ${SPACING.MD}px;
+ border-radius: ${BORDER_RADIUS.SM}px;
+ font-size: ${FONT_SIZES.SM}px;
+ font-weight: ${FONT_WEIGHTS.MEDIUM};
+ white-space: nowrap;
+ backdrop-filter: blur(${GLASS_EFFECT.BLUR_STRENGTH.SUBTLE}px);
+ border: 1px solid rgba(255, 255, 255, ${GLASS_EFFECT.BORDER_ALPHA.SUBTLE});
+ box-shadow: ${SHADOWS.SM};
+`
diff --git a/src/renderer/src/pages/player/engine/PlayerOrchestrator.ts b/src/renderer/src/pages/player/engine/PlayerOrchestrator.ts
index f1c3ae80..8b9e82af 100644
--- a/src/renderer/src/pages/player/engine/PlayerOrchestrator.ts
+++ b/src/renderer/src/pages/player/engine/PlayerOrchestrator.ts
@@ -249,10 +249,48 @@ export class PlayerOrchestrator {
}
try {
+ // 记录播放前的状态
+ const wasActuallyPaused = this.videoController.isPaused()
+ const wasContextPaused = this.context.paused
+
+ logger.debug('requestPlay initiated', {
+ wasActuallyPaused,
+ wasContextPaused,
+ currentTime: this.context.currentTime
+ })
+
+ // 乐观更新内部状态
+ if (wasContextPaused) {
+ this.updateContext({ paused: false })
+ }
+
await this.videoController.play()
- logger.debug('Command: requestPlay executed')
+ logger.debug('Command: requestPlay executed successfully')
+
+ // 延迟验证播放是否真正开始
+ setTimeout(() => {
+ if (this.videoController?.isPaused()) {
+ logger.warn('Video element still paused after play() call, investigating...', {
+ contextPaused: this.context.paused,
+ videoPaused: this.videoController?.isPaused()
+ })
+
+ // 尝试再次播放
+ this.videoController?.play().catch((retryError) => {
+ logger.error('Retry play() also failed:', { retryError })
+ // 回滚乐观更新
+ this.updateContext({ paused: true })
+ })
+ }
+ }, 150)
} catch (error) {
logger.error('Failed to execute requestPlay:', { error })
+ // 回滚乐观更新
+ this.updateContext({ paused: true })
+
+ // 尝试强制同步状态
+ this.syncPlaybackState()
+ // 不重新抛出异常,让调用者能正常处理
}
}
@@ -265,8 +303,47 @@ export class PlayerOrchestrator {
return
}
- this.videoController.pause()
- logger.debug('Command: requestPause executed')
+ try {
+ // 记录暂停前的状态
+ const wasActuallyPaused = this.videoController.isPaused()
+ const wasContextPaused = this.context.paused
+
+ logger.debug('requestPause initiated', {
+ wasActuallyPaused,
+ wasContextPaused,
+ currentTime: this.context.currentTime
+ })
+
+ // 乐观更新内部状态
+ if (!wasContextPaused) {
+ this.updateContext({ paused: true })
+ }
+
+ this.videoController.pause()
+ logger.debug('Command: requestPause executed successfully')
+
+ // 延迟验证暂停是否真正生效
+ setTimeout(() => {
+ if (!this.videoController?.isPaused()) {
+ logger.warn('Video element still playing after pause() call, investigating...', {
+ contextPaused: this.context.paused,
+ videoPaused: this.videoController?.isPaused()
+ })
+
+ // 尝试再次暂停
+ this.videoController?.pause()
+ // 强制同步状态
+ this.syncPlaybackState()
+ }
+ }, 50)
+ } catch (error) {
+ logger.error('Failed to execute requestPause:', { error })
+ // 回滚乐观更新
+ this.updateContext({ paused: false })
+
+ // 尝试强制同步状态
+ this.syncPlaybackState()
+ }
}
/**
@@ -278,10 +355,34 @@ export class PlayerOrchestrator {
return
}
- if (this.videoController.isPaused()) {
- await this.requestPlay()
- } else {
- this.requestPause()
+ // 使用内部上下文状态而非直接查询视频元素,避免状态不同步问题
+ const isPaused = this.context.paused
+
+ try {
+ if (isPaused) {
+ await this.requestPlay()
+ // 验证播放操作是否成功
+ setTimeout(() => {
+ if (this.videoController?.isPaused() && !this.context.paused) {
+ logger.warn('Play command failed to take effect, attempting sync')
+ this.syncPlaybackState()
+ }
+ }, 100)
+ } else {
+ this.requestPause()
+ // 验证暂停操作是否成功
+ setTimeout(() => {
+ if (!this.videoController?.isPaused() && this.context.paused) {
+ logger.warn('Pause command failed to take effect, attempting sync')
+ this.syncPlaybackState()
+ }
+ }, 50)
+ }
+ } catch (error) {
+ logger.error('Failed to toggle play state:', { error, isPaused })
+ // 尝试强制同步状态
+ this.syncPlaybackState()
+ // 不重新抛出异常,让调用者能正常处理
}
}
@@ -514,6 +615,47 @@ export class PlayerOrchestrator {
// === 私有方法 ===
+ /**
+ * 同步播放状态 - 解决内部状态与视频元素状态不一致的问题
+ */
+ private syncPlaybackState(): void {
+ if (!this.videoController) return
+
+ const actualPaused = this.videoController.isPaused()
+ const contextPaused = this.context.paused
+
+ if (actualPaused !== contextPaused) {
+ logger.warn('Playback state mismatch detected, syncing...', {
+ contextPaused,
+ actualPaused,
+ currentTime: this.context.currentTime
+ })
+
+ // 更新内部上下文状态
+ this.updateContext({ paused: actualPaused })
+
+ // 同步到外部状态管理器
+ this.stateUpdater?.setPlaying(!actualPaused)
+
+ // 同步调度器状态
+ if (actualPaused) {
+ if (this.clockScheduler.getState() !== 'paused') {
+ this.clockScheduler.pause()
+ logger.debug('ClockScheduler synced to paused state')
+ }
+ } else {
+ if (this.clockScheduler.getState() !== 'running') {
+ this.clockScheduler.resume()
+ logger.debug('ClockScheduler synced to running state')
+ }
+ }
+
+ logger.debug('Playback state synchronized successfully', {
+ newState: actualPaused ? 'paused' : 'playing'
+ })
+ }
+ }
+
/**
* 重置播放器状态(用户主动跳转时)
* 清理未发布的意图、重置字幕锁定状态、重载所有策略
diff --git a/src/renderer/src/pages/player/engine/__tests__/PlayerOrchestrator.playback-reliability.test.ts b/src/renderer/src/pages/player/engine/__tests__/PlayerOrchestrator.playback-reliability.test.ts
new file mode 100644
index 00000000..6eb7d42b
--- /dev/null
+++ b/src/renderer/src/pages/player/engine/__tests__/PlayerOrchestrator.playback-reliability.test.ts
@@ -0,0 +1,347 @@
+import { LoopMode } from '@types'
+import { afterEach, beforeEach, describe, expect, it, type Mocked, vi } from 'vitest'
+
+import { PlayerOrchestrator, StateUpdater, VideoController } from '../PlayerOrchestrator'
+
+describe('PlayerOrchestrator - 播放/暂停可靠性测试', () => {
+ let orchestrator: PlayerOrchestrator
+ let mockVideoController: Mocked
+ let mockStateUpdater: Mocked
+ let mockClockScheduler: any
+
+ const context = {
+ currentTime: 10,
+ duration: 100,
+ paused: true, // 初始状态为暂停
+ playbackRate: 1,
+ activeCueIndex: -1,
+ subtitles: [],
+ loopEnabled: false,
+ loopMode: LoopMode.SINGLE,
+ loopCount: -1,
+ loopRemainingCount: -1,
+ autoPauseEnabled: false,
+ pauseOnSubtitleEnd: false,
+ resumeEnabled: false,
+ resumeDelay: 5000,
+ volume: 1
+ }
+
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ beforeEach(() => {
+ // Mock console methods to avoid test output pollution
+ vi.spyOn(console, 'debug').mockImplementation(() => {})
+ vi.spyOn(console, 'warn').mockImplementation(() => {})
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ // 创建 mock VideoController
+ mockVideoController = {
+ play: vi.fn().mockResolvedValue(undefined),
+ pause: vi.fn(),
+ seek: vi.fn(),
+ setPlaybackRate: vi.fn(),
+ setVolume: vi.fn(),
+ setMuted: vi.fn(),
+ getCurrentTime: vi.fn().mockReturnValue(10),
+ getDuration: vi.fn().mockReturnValue(100),
+ isPaused: vi.fn().mockReturnValue(true), // 初始状态为暂停
+ getPlaybackRate: vi.fn().mockReturnValue(1),
+ getVolume: vi.fn().mockReturnValue(1),
+ isMuted: vi.fn().mockReturnValue(false)
+ }
+
+ // 创建 mock StateUpdater
+ mockStateUpdater = {
+ setCurrentTime: vi.fn(),
+ setDuration: vi.fn(),
+ setPlaying: vi.fn(),
+ updateLoopRemaining: vi.fn(),
+ setPlaybackRate: vi.fn(),
+ setVolume: vi.fn(),
+ setMuted: vi.fn(),
+ setSeeking: vi.fn(),
+ setEnded: vi.fn(),
+ updateUIState: vi.fn()
+ }
+
+ // 初始化 orchestrator
+ orchestrator = new PlayerOrchestrator({ ...context })
+ orchestrator.connectVideoController(mockVideoController)
+ orchestrator.connectStateUpdater(mockStateUpdater)
+
+ // 访问私有的 clockScheduler 进行 mock
+ const scheduler = (orchestrator as any).clockScheduler
+ if (scheduler) {
+ mockClockScheduler = scheduler
+ vi.spyOn(scheduler, 'getState').mockReturnValue('paused')
+ vi.spyOn(scheduler, 'pause').mockImplementation(() => {})
+ vi.spyOn(scheduler, 'resume').mockImplementation(() => {})
+ }
+ })
+
+ describe('requestTogglePlay - 使用内部状态修复', () => {
+ it('应该使用内部上下文状态而非视频元素状态来判断播放/暂停', async () => {
+ // 设置场景:内部状态为暂停,但视频元素状态为播放(状态不同步)
+ orchestrator.updateContext({ paused: true })
+ mockVideoController.isPaused.mockReturnValue(false) // 视频元素状态不同步
+
+ await orchestrator.requestTogglePlay()
+
+ // 应该根据内部状态(true)执行播放操作,而不是根据视频元素状态(false)
+ expect(mockVideoController.play).toHaveBeenCalled()
+ expect(mockVideoController.pause).not.toHaveBeenCalled()
+ })
+
+ it('应该正确处理内部状态为播放时的切换', async () => {
+ // 设置场景:内部状态为播放
+ orchestrator.updateContext({ paused: false })
+ mockVideoController.isPaused.mockReturnValue(true) // 视频元素状态不同步
+
+ await orchestrator.requestTogglePlay()
+
+ // 应该根据内部状态(false)执行暂停操作
+ expect(mockVideoController.pause).toHaveBeenCalled()
+ expect(mockVideoController.play).not.toHaveBeenCalled()
+ })
+
+ it('应该处理播放操作失败的情况', async () => {
+ const playError = new Error('DOMException: play() failed')
+ mockVideoController.play.mockRejectedValue(playError)
+ orchestrator.updateContext({ paused: true })
+
+ // 不应该抛出异常
+ await expect(orchestrator.requestTogglePlay()).resolves.toBeUndefined()
+
+ // 应该记录错误并尝试同步状态
+ expect(mockVideoController.play).toHaveBeenCalled()
+ })
+
+ it('应该在播放失败后验证状态并尝试同步', async () => {
+ orchestrator.updateContext({ paused: true })
+ mockVideoController.isPaused.mockReturnValue(true)
+
+ await orchestrator.requestTogglePlay()
+
+ // 使用 setTimeout 模拟延迟验证
+ await new Promise((resolve) => setTimeout(resolve, 150))
+
+ // 验证播放调用确实发生了
+ expect(mockVideoController.play).toHaveBeenCalled()
+ })
+ })
+
+ describe('syncPlaybackState - 状态同步修复', () => {
+ it('应该检测到内部状态与视频元素状态不一致', () => {
+ // 设置不一致的状态
+ orchestrator.updateContext({ paused: true }) // 内部状态:暂停
+ mockVideoController.isPaused.mockReturnValue(false) // 视频状态:播放
+
+ // 调用私有方法进行状态同步测试
+ const syncMethod = (orchestrator as any).syncPlaybackState.bind(orchestrator)
+ syncMethod()
+
+ // 应该同步内部状态到视频元素的实际状态
+ const newContext = orchestrator.getContext()
+ expect(newContext.paused).toBe(false) // 应该被同步为播放状态
+ expect(mockStateUpdater.setPlaying).toHaveBeenCalledWith(true)
+ })
+
+ it('应该同步ClockScheduler状态', () => {
+ // 设置状态不一致
+ orchestrator.updateContext({ paused: false }) // 内部状态:播放
+ mockVideoController.isPaused.mockReturnValue(true) // 视频状态:暂停
+
+ if (mockClockScheduler) {
+ mockClockScheduler.getState.mockReturnValue('running') // 调度器状态:运行中
+ }
+
+ // 调用状态同步
+ const syncMethod = (orchestrator as any).syncPlaybackState.bind(orchestrator)
+ syncMethod()
+
+ // 应该同步调度器状态
+ if (mockClockScheduler) {
+ expect(mockClockScheduler.pause).toHaveBeenCalled()
+ }
+ })
+
+ it('当状态一致时不应该进行不必要的同步操作', () => {
+ // 设置一致的状态
+ orchestrator.updateContext({ paused: true })
+ mockVideoController.isPaused.mockReturnValue(true)
+
+ const syncMethod = (orchestrator as any).syncPlaybackState.bind(orchestrator)
+ syncMethod()
+
+ // 不应该调用状态更新方法
+ expect(mockStateUpdater.setPlaying).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('requestPlay - 乐观更新和验证', () => {
+ it('应该立即更新内部状态(乐观更新)', async () => {
+ orchestrator.updateContext({ paused: true })
+
+ // 让play()调用需要一些时间
+ mockVideoController.play.mockImplementation(
+ () => new Promise((resolve) => setTimeout(resolve, 50))
+ )
+
+ const playPromise = orchestrator.requestPlay()
+
+ // play()调用完成前,内部状态应该已经更新
+ const contextDuringPlay = orchestrator.getContext()
+ expect(contextDuringPlay.paused).toBe(false)
+
+ await playPromise
+ })
+
+ it('应该在播放失败时回滚乐观更新', async () => {
+ orchestrator.updateContext({ paused: true })
+ const playError = new Error('Play failed')
+ mockVideoController.play.mockRejectedValue(playError)
+
+ await orchestrator.requestPlay()
+
+ // 状态应该被回滚到暂停
+ const finalContext = orchestrator.getContext()
+ expect(finalContext.paused).toBe(true)
+ })
+
+ it('应该在延迟验证中检测播放失败', async () => {
+ orchestrator.updateContext({ paused: true })
+ mockVideoController.isPaused.mockReturnValue(true) // 播放后仍然是暂停状态
+
+ await orchestrator.requestPlay()
+
+ // 等待延迟验证执行
+ await new Promise((resolve) => setTimeout(resolve, 200))
+
+ // 应该尝试重试播放
+ expect(mockVideoController.play).toHaveBeenCalledTimes(2) // 一次正常调用,一次重试
+ })
+ })
+
+ describe('requestPause - 状态验证', () => {
+ it('应该立即更新内部状态', () => {
+ orchestrator.updateContext({ paused: false })
+
+ orchestrator.requestPause()
+
+ // 内部状态应该立即更新
+ const context = orchestrator.getContext()
+ expect(context.paused).toBe(true)
+ })
+
+ it('应该在延迟验证中检测暂停失败', async () => {
+ orchestrator.updateContext({ paused: false })
+ mockVideoController.isPaused.mockReturnValue(false) // 暂停后仍然是播放状态
+
+ orchestrator.requestPause()
+
+ // 等待延迟验证
+ await new Promise((resolve) => setTimeout(resolve, 100))
+
+ // 应该尝试重试暂停
+ expect(mockVideoController.pause).toHaveBeenCalledTimes(2)
+ })
+ })
+
+ describe('错误恢复和边界情况', () => {
+ it('应该处理videoController未连接的情况', async () => {
+ const orphanOrchestrator = new PlayerOrchestrator(context)
+
+ // 不应该抛出异常
+ await expect(orphanOrchestrator.requestTogglePlay()).resolves.toBeUndefined()
+ await expect(orphanOrchestrator.requestPlay()).resolves.toBeUndefined()
+ expect(() => orphanOrchestrator.requestPause()).not.toThrow()
+ })
+
+ it('应该处理同步状态时videoController为null的情况', () => {
+ const orphanOrchestrator = new PlayerOrchestrator(context)
+ const syncMethod = (orphanOrchestrator as any).syncPlaybackState.bind(orphanOrchestrator)
+
+ // 不应该抛出异常
+ expect(() => syncMethod()).not.toThrow()
+ })
+
+ it('应该处理连续快速的播放/暂停切换', async () => {
+ orchestrator.updateContext({ paused: true })
+
+ // 快速连续调用
+ const promise1 = orchestrator.requestTogglePlay()
+ const promise2 = orchestrator.requestTogglePlay()
+ const promise3 = orchestrator.requestTogglePlay()
+
+ await Promise.all([promise1, promise2, promise3])
+
+ // 应该至少有一次播放调用
+ expect(mockVideoController.play).toHaveBeenCalled()
+ })
+
+ it('应该处理play()返回rejected Promise的情况', async () => {
+ const notAllowedError = new DOMException('play() failed', 'NotAllowedError')
+ mockVideoController.play.mockRejectedValue(notAllowedError)
+ orchestrator.updateContext({ paused: true })
+
+ // 不应该抛出未捕获的异常
+ await expect(orchestrator.requestTogglePlay()).resolves.toBeUndefined()
+
+ // 状态应该被正确恢复
+ const context = orchestrator.getContext()
+ expect(context.paused).toBe(true)
+ })
+ })
+
+ describe('实际使用场景模拟', () => {
+ it('应该正确处理用户快捷键触发的播放/暂停', async () => {
+ // 模拟用户按下空格键时的状态
+ orchestrator.updateContext({ paused: true, currentTime: 30.5 })
+ mockVideoController.getCurrentTime.mockReturnValue(30.5)
+
+ await orchestrator.requestTogglePlay()
+
+ expect(mockVideoController.play).toHaveBeenCalled()
+
+ // 模拟播放成功后再次按空格暂停
+ orchestrator.updateContext({ paused: false })
+ await orchestrator.requestTogglePlay()
+
+ expect(mockVideoController.pause).toHaveBeenCalled()
+ })
+
+ it('应该正确处理浏览器自动暂停后的恢复播放', async () => {
+ // 模拟浏览器自动暂停(例如标签页失去焦点)
+ orchestrator.updateContext({ paused: false }) // 内部认为还在播放
+ mockVideoController.isPaused.mockReturnValue(true) // 但实际已暂停
+
+ // 用户尝试恢复播放
+ await orchestrator.requestTogglePlay()
+
+ // 应该调用暂停(因为内部状态为播放)
+ expect(mockVideoController.pause).toHaveBeenCalled()
+
+ // 然后状态同步应该修正这个问题
+ const syncMethod = (orchestrator as any).syncPlaybackState.bind(orchestrator)
+ syncMethod()
+
+ const newContext = orchestrator.getContext()
+ expect(newContext.paused).toBe(true) // 状态已同步
+ })
+
+ it('应该处理网络问题导致的播放失败', async () => {
+ const networkError = new DOMException('Network error', 'NetworkError')
+ mockVideoController.play.mockRejectedValue(networkError)
+ orchestrator.updateContext({ paused: true })
+
+ await orchestrator.requestPlay()
+
+ // 状态应该被回滚
+ const context = orchestrator.getContext()
+ expect(context.paused).toBe(true)
+ })
+ })
+})
diff --git a/src/renderer/src/pages/player/engine/__tests__/PlayerOrchestrator.test.ts b/src/renderer/src/pages/player/engine/__tests__/PlayerOrchestrator.test.ts
index a26ec9f0..611d68a5 100644
--- a/src/renderer/src/pages/player/engine/__tests__/PlayerOrchestrator.test.ts
+++ b/src/renderer/src/pages/player/engine/__tests__/PlayerOrchestrator.test.ts
@@ -79,13 +79,15 @@ describe('PlayerOrchestrator - 命令系统测试', () => {
})
it('should handle requestTogglePlay command when paused', async () => {
- mockVideoController.isPaused.mockReturnValue(true)
+ // 修复:设置内部上下文状态为暂停,这是新的逻辑依赖
+ orchestrator.updateContext({ paused: true })
await orchestrator.requestTogglePlay()
expect(mockVideoController.play).toHaveBeenCalled()
})
it('should handle requestTogglePlay command when playing', async () => {
- mockVideoController.isPaused.mockReturnValue(false)
+ // 修复:设置内部上下文状态为播放,这是新的逻辑依赖
+ orchestrator.updateContext({ paused: false })
await orchestrator.requestTogglePlay()
expect(mockVideoController.pause).toHaveBeenCalled()
})
diff --git a/src/renderer/src/pages/player/hooks/usePlayerCommands.ts b/src/renderer/src/pages/player/hooks/usePlayerCommands.ts
index a50dfe43..6d13366a 100644
--- a/src/renderer/src/pages/player/hooks/usePlayerCommands.ts
+++ b/src/renderer/src/pages/player/hooks/usePlayerCommands.ts
@@ -32,11 +32,25 @@ export function usePlayerCommands() {
return
}
+ const context = orchestrator.getContext()
+ const isCurrentlyPaused = orchestrator.isPaused()
+
+ logger.debug('playPause command initiated', {
+ contextPaused: context.paused,
+ actuallyPaused: isCurrentlyPaused,
+ currentTime: context.currentTime,
+ timestamp: Date.now()
+ })
+
try {
await orchestrator.requestTogglePlay()
- logger.info('Command: playPause executed')
+ logger.info('Command: playPause executed successfully')
} catch (error) {
- logger.error('Failed to execute playPause command:', { error })
+ logger.error('Failed to execute playPause command:', {
+ error,
+ contextPaused: context.paused,
+ actuallyPaused: isCurrentlyPaused
+ })
}
}, [orchestrator])
diff --git a/src/renderer/src/pages/player/hooks/usePlayerShortcuts.ts b/src/renderer/src/pages/player/hooks/usePlayerShortcuts.ts
index 1c205742..51ea4612 100644
--- a/src/renderer/src/pages/player/hooks/usePlayerShortcuts.ts
+++ b/src/renderer/src/pages/player/hooks/usePlayerShortcuts.ts
@@ -2,16 +2,99 @@ import { loggerService } from '@logger'
import { useShortcut } from '@renderer/infrastructure/hooks/useShortcut'
import { usePlayerStore } from '@renderer/state/stores/player.store'
import { SubtitleDisplayMode } from '@types'
+import { useCallback } from 'react'
+import { useTranslation } from 'react-i18next'
import { usePlayerCommands } from './usePlayerCommands'
import useSubtitleOverlay from './useSubtitleOverlay'
const logger = loggerService.withContext('TransportBar')
+/**
+ * Registers global keyboard shortcuts for player controls and subtitle-related actions.
+ *
+ * Sets up shortcuts for playback (play/pause, seek, volume, loop), subtitle navigation
+ * (previous/next, replay), subtitle display mode toggles (none/original/translated/bilingual),
+ * subtitle panel toggle, cycling favorite playback rates, and copying the current subtitle to the clipboard.
+ *
+ * The copy action selects text according to the current subtitle display mode:
+ * - ORIGINAL: original text
+ * - TRANSLATED: translated text, falling back to original if missing
+ * - BILINGUAL: original and translated joined by a newline
+ * - NONE or unsupported: no copy performed
+ *
+ * Side effects:
+ * - Invokes player command functions and store actions.
+ * - Writes subtitle text to the clipboard via `navigator.clipboard.writeText`.
+ * - Emits a `CustomEvent` named `subtitle-copied` with a localized success or failure message.
+ * - Logs informational and error events via the module logger.
+ */
export function usePlayerShortcuts() {
+ const { t } = useTranslation()
const cmd = usePlayerCommands()
- const { setDisplayMode } = useSubtitleOverlay()
+ const { setDisplayMode, currentSubtitle } = useSubtitleOverlay()
const { toggleSubtitlePanel, cycleFavoriteRateNext, cycleFavoriteRatePrev } = usePlayerStore()
+ const displayMode = usePlayerStore((s) => s.subtitleOverlay.displayMode)
+
+ // 复制字幕内容处理函数
+ const handleCopySubtitle = useCallback(async () => {
+ try {
+ let textToCopy = ''
+
+ if (currentSubtitle) {
+ // 根据显示模式复制相应的字幕内容
+ switch (displayMode) {
+ case SubtitleDisplayMode.ORIGINAL:
+ textToCopy = currentSubtitle.originalText
+ break
+ case SubtitleDisplayMode.TRANSLATED:
+ textToCopy = currentSubtitle.translatedText || currentSubtitle.originalText
+ break
+ case SubtitleDisplayMode.BILINGUAL: {
+ const texts = [currentSubtitle.originalText, currentSubtitle.translatedText].filter(
+ Boolean
+ )
+ textToCopy = texts.join('\n')
+ break
+ }
+ default:
+ logger.warn('当前显示模式不支持复制')
+ return // NONE 模式不复制
+ }
+ logger.info('复制字幕内容', {
+ mode: displayMode,
+ length: textToCopy.length
+ })
+ } else {
+ logger.warn('没有当前字幕内容')
+ return
+ }
+
+ if (textToCopy) {
+ await navigator.clipboard.writeText(textToCopy)
+
+ // 触发自定义事件显示toast
+ window.dispatchEvent(
+ new CustomEvent('subtitle-copied', {
+ detail: {
+ message: t('player.controls.copy.success')
+ }
+ })
+ )
+ }
+ } catch (error) {
+ logger.error('复制字幕失败', { error })
+
+ // 错误情况下也使用toast显示
+ window.dispatchEvent(
+ new CustomEvent('subtitle-copied', {
+ detail: {
+ message: t('player.controls.copy.failed')
+ }
+ })
+ )
+ }
+ }, [currentSubtitle, displayMode, t])
useShortcut('play_pause', () => {
cmd.playPause()
@@ -87,4 +170,9 @@ export function usePlayerShortcuts() {
cycleFavoriteRatePrev()
logger.info('播放速度切换: 上一个常用速度')
})
+
+ // 复制字幕内容
+ useShortcut('copy_subtitle', () => {
+ handleCopySubtitle()
+ })
}
diff --git a/src/renderer/src/pages/player/hooks/useSubtitleOverlay.ts b/src/renderer/src/pages/player/hooks/useSubtitleOverlay.ts
index e6ddf397..4470f5f4 100644
--- a/src/renderer/src/pages/player/hooks/useSubtitleOverlay.ts
+++ b/src/renderer/src/pages/player/hooks/useSubtitleOverlay.ts
@@ -36,6 +36,9 @@ export function useSubtitleOverlay(): SubtitleOverlay {
// 字幕引擎
const { currentSubtitle, currentIndex } = useSubtitleEngine()
+ // 当前播放时间
+ const currentTime = usePlayerStore((s) => s.currentTime)
+
const subtitleOverlayConfig = usePlayerStore((s) => s.subtitleOverlay)
const setSubtitleOverlay = usePlayerStore((s) => s.setSubtitleOverlay)
@@ -54,10 +57,17 @@ export function useSubtitleOverlay(): SubtitleOverlay {
// === 计算是否应该显示 ===
const shouldShow = useMemo(() => {
- return (
- subtitleOverlayConfig.displayMode !== SubtitleDisplayMode.NONE && currentSubtitleData !== null
- )
- }, [subtitleOverlayConfig.displayMode, currentSubtitleData])
+ // 基础条件:显示模式不为 NONE 且有字幕数据
+ if (subtitleOverlayConfig.displayMode === SubtitleDisplayMode.NONE || !currentSubtitleData) {
+ return false
+ }
+
+ // 时间边界检查:确保当前播放时间在字幕的时间范围内
+ const isInTimeRange =
+ currentTime >= currentSubtitleData.startTime && currentTime <= currentSubtitleData.endTime
+
+ return isInTimeRange
+ }, [subtitleOverlayConfig.displayMode, currentSubtitleData, currentTime])
// === 计算显示文本 ===
const displayText = useMemo(() => {