diff --git a/.gitattributes b/.gitattributes index 20570f2f..fef71287 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,5 @@ * text eol=lf -*.exe binary +*.exe filter=lfs diff=lfs merge=lfs -text *.png binary *.jpg binary *.jpeg binary @@ -10,3 +10,10 @@ *.ttf binary *.woff binary *.woff2 binary +*.mkv filter=lfs diff=lfs merge=lfs -text +*.avi filter=lfs diff=lfs merge=lfs -text +*.mov filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.tar.gz filter=lfs diff=lfs merge=lfs -text +*.dll filter=lfs diff=lfs merge=lfs -text +*.mp4 filter=lfs diff=lfs merge=lfs -text diff --git a/README.md b/README.md index 893e4e7b..ca2d7470 100644 --- a/README.md +++ b/README.md @@ -93,12 +93,6 @@ https://github.com/solidSpoon/DashPlayer/assets/39454841/c243796b-7a4c-400c-99c9 https://github.com/solidSpoon/DashPlayer/assets/39454841/55956719-306f-4046-a8b4-243f79029d26 - -在字幕上点击并滑动可以循环播放多行字幕 - -https://github.com/user-attachments/assets/82b2cb36-a44b-4729-9b4f-3a440c6deb40 - - --- # 安装指南 diff --git a/scripts/download.mjs b/scripts/download.mjs index 295c15b6..0c46092e 100644 --- a/scripts/download.mjs +++ b/scripts/download.mjs @@ -166,37 +166,27 @@ const extractZip = async (zipPath, destDir) => { } // NOTE: `pwsh -Command ` consumes the remainder of the command line, so extra args are not reliably // available in `$args` on CI shells. Use `-File` to pass zip/dest as proper script arguments. - const psTmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashplayer-ps-')); - const psFile = path.join(psTmpDir, 'expand-archive.ps1'); - fs.writeFileSync( - psFile, - [ - 'param(', - ' [Parameter(Mandatory = $true)][string]$Zip,', - ' [Parameter(Mandatory = $true)][string]$Dest', - ')', - 'Expand-Archive -Force -LiteralPath $Zip -DestinationPath $Dest', - '', - ].join('\n') - ); - // Avoid `\e` escape sequences when zx renders arguments through a bash-like layer on Windows. - const psFileArg = String(psFile).replaceAll('\\', '/'); - const zipArg = String(zipPath).replaceAll('\\', '/'); - const destArg = String(destDir).replaceAll('\\', '/'); + // However, the PowerShell profile can interfere with -File (see above). To avoid this, we call + // Expand-Archive directly from Node.js child_process instead of spawning a ps1 script. + const { exec } = await import('child_process'); + const escapedZip = zipPath.replace(/'/g, "''"); + const escapedDest = destDir.replace(/'/g, "''"); + const psCmd = `Expand-Archive -Force -LiteralPath '${escapedZip}' -DestinationPath '${escapedDest}'`; + // Try Windows PowerShell first, fall back to pwsh + const execAsync = (cmd) => new Promise((resolve, reject) => { + exec(cmd, { shell: 'cmd.exe', windowsHide: true }, (err, _stdout, stderr) => { + if (err) reject(new Error(stderr || err.message)); + else resolve(); + }); + }); try { - await $`pwsh -NoProfile -ExecutionPolicy Bypass -File ${psFileArg} ${zipArg} ${destArg}`; + await execAsync(`powershell -NoProfile -ExecutionPolicy Bypass -Command "${psCmd}"`); } catch { - await $`powershell -NoProfile -ExecutionPolicy Bypass -File ${psFileArg} ${zipArg} ${destArg}`; - } finally { - try { - fs.rmSync(psTmpDir, { recursive: true, force: true }); - } catch { - // ignore - } + await execAsync(`pwsh -NoProfile -ExecutionPolicy Bypass -Command "${psCmd}"`); } - return; + } else { + await $`unzip -o ${zipPath} -d ${destDir}`; } - await $`unzip -o ${zipPath} -d ${destDir}`; }; const extractTarGz = async (tarPath, destDir) => { @@ -432,10 +422,7 @@ const arch = process.env.npm_config_arch || os.arch() const archKey = arch === 'arm64' ? 'arm64' : 'x64'; const nameRegex = new RegExp(`^whisper\\.cpp-${platKey}-${archKey}\\.(zip|tar\\.gz|tgz)$`, 'i'); - let assetUrl = await getLatestReleaseAssetUrl({ owner: 'solidSpoon', repo: 'DashPlayer', nameRegex }); - if (!assetUrl) { - assetUrl = await getLatestReleaseAssetUrlIncludingPrerelease({ owner: 'solidSpoon', repo: 'DashPlayer', nameRegex }); - } + const assetUrl = await getLatestReleaseAssetUrlIncludingPrerelease({ owner: 'solidSpoon', repo: 'DashPlayer', nameRegex }); if (!assetUrl) { console.warn(chalk.yellow(`⚠️ whisper.cpp release asset not found for ${platform}/${arch}, skip download`)); } else { @@ -444,6 +431,8 @@ const arch = process.env.npm_config_arch || os.arch() extraCopyPatterns.push(/^(libwhisper|libggml).*\.dylib$/i); } else if (platform === 'linux') { extraCopyPatterns.push(/^(libwhisper|libggml).*\.so(\.\d+)?$/i); + } else if (platform === 'win32') { + extraCopyPatterns.push(/\.(dll|lib)$/i); } console.info(chalk.blue(`=> whisper.cpp target: ${exePath}`)); await downloadAndExtractBinaryFromArchive({ @@ -458,3 +447,27 @@ const arch = process.env.npm_config_arch || os.arch() } } } + +{ + // yt-dlp + const file = platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp'; + const res = await verifyExistence({ + dir, + file, + }); + if (res === 'need_download') { + const downloadUrl = platform === 'win32' + ? 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp.exe' + : platform === 'darwin' + ? 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp_macos' + : 'https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp'; + + console.info(chalk.blue(`=> yt-dlp target: ${path.join(dir, file)}`)); + await download({ + url: downloadUrl, + dir, + file, + }); + fs.chmodSync(path.join(dir, file), 0o755); + } +} diff --git a/src/app.tsx b/src/app.tsx index 02f312a4..0ce97cc3 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -26,6 +26,7 @@ import Convert from '@/fronted/pages/convert/Convert'; import Eb from '@/fronted/components/shared/common/Eb'; import Favorite from '@/fronted/pages/favourite'; import VideoLearningPage from '@/fronted/pages/video-learning'; +import DownloadPage from '@/fronted/pages/download/Download'; import { startListeningToDpTasks } from '@/fronted/hooks/useDpTaskCenter'; import { toast as sonnerToast } from 'sonner'; import { backendClient } from '@/fronted/application/bootstrap/backendClient'; @@ -109,6 +110,10 @@ const App = () => { path="vocabulary" element={} /> + } + /> } /> { + return this.downloadService.startDownload(params.url, params.savePath); + } + + public async getMetadata(url: string): Promise { + return this.downloadService.getMetadata(url); + } + + registerRoutes(): void { + registerRoute('download/start', (p) => this.start(p)); + registerRoute('download/get-metadata', (p) => this.getMetadata(p)); + } +} diff --git a/src/backend/adapters/controllers/VocabularyController.ts b/src/backend/adapters/controllers/VocabularyController.ts index 28df2079..195add1d 100644 --- a/src/backend/adapters/controllers/VocabularyController.ts +++ b/src/backend/adapters/controllers/VocabularyController.ts @@ -20,9 +20,24 @@ export default class VocabularyController implements Controller { return this.vocabularyService.importWords(params.filePath); } + public async addWord(params: { word: string; translate?: string }) { + return this.vocabularyService.addWord(params); + } + + public async deleteWord(params: { word: string }) { + return this.vocabularyService.deleteWord(params.word); + } + + public async refreshTranslation(params: { word: string }) { + return this.vocabularyService.refreshWordTranslation(params.word); + } + registerRoutes(): void { registerRoute('vocabulary/get-all', (p) => this.getAllWords(p)); registerRoute('vocabulary/export-template', () => this.exportTemplate()); registerRoute('vocabulary/import', (p) => this.importWords(p)); + registerRoute('vocabulary/add', (p) => this.addWord(p)); + registerRoute('vocabulary/delete', (p) => this.deleteWord(p)); + registerRoute('vocabulary/refresh-translation', (p) => this.refreshTranslation(p)); } } diff --git a/src/backend/adapters/ipc/registerRoute.ts b/src/backend/adapters/ipc/registerRoute.ts index e71e315d..216641a7 100644 --- a/src/backend/adapters/ipc/registerRoute.ts +++ b/src/backend/adapters/ipc/registerRoute.ts @@ -80,9 +80,6 @@ export default function registerRoute(path: K, func: Api const costMs = Date.now() - start; const message = error instanceof Error ? error.message : String(error); logger.error(`api-error path=${String(path)} costMs=${costMs} message=${preview(message, 300)}`, { error }); - container - .get(TYPES.RendererEvents) - .error(error instanceof Error ? error : new Error(String(error))); throw error; } }); diff --git a/src/backend/application/ports/gateways/media/DownloadGateway.ts b/src/backend/application/ports/gateways/media/DownloadGateway.ts new file mode 100644 index 00000000..d3ae3a95 --- /dev/null +++ b/src/backend/application/ports/gateways/media/DownloadGateway.ts @@ -0,0 +1,19 @@ +export interface DownloadMetadata { + title: string; + thumbnail?: string; + url: string; + duration?: number; +} + +export interface DownloadOptions { + taskId: number; + url: string; + savePath: string; + onProgress?: (percent: number, speed?: string, eta?: string) => void; + onCancelable?: (cancel: () => void) => void; +} + +export default interface DownloadGateway { + getMetadata(url: string): Promise; + download(options: DownloadOptions): Promise; +} diff --git a/src/backend/application/ports/repositories/WordsRepository.ts b/src/backend/application/ports/repositories/WordsRepository.ts index 7f22274c..2ce9e18f 100644 --- a/src/backend/application/ports/repositories/WordsRepository.ts +++ b/src/backend/application/ports/repositories/WordsRepository.ts @@ -13,4 +13,25 @@ export interface GetAllWordsQuery { export default interface WordsRepository { getAll(query?: GetAllWordsQuery): Promise; replaceAll(values: InsertWord[]): Promise; + /** + * 添加单词到生词本。 + * word 字段在 DB 层有 UNIQUE 约束,重复插入使用 onConflictDoNothing 静默忽略。 + * + * @param word 单词原文(调用方负责归一化处理)。 + * @param translate 可选释义。 + */ + addWord(word: string, translate?: string): Promise; + + /** + * 更新单词释义。 + * @param word 单词原文。 + * @param translate 释义内容。 + */ + updateWord(word: string, translate: string): Promise; + + /** + * 从生词本中删除单词。 + * @param word 单词原文。 + */ + deleteWord(word: string): Promise; } diff --git a/src/backend/application/services/CheckUpdate.ts b/src/backend/application/services/CheckUpdate.ts index 80fb1280..fe45c4ea 100644 --- a/src/backend/application/services/CheckUpdate.ts +++ b/src/backend/application/services/CheckUpdate.ts @@ -90,10 +90,10 @@ export const checkUpdate = async (): Promise => { return cache; } - const releases: Release[] = listResponse.data - .filter(isStableRelease) + const releases: Release[] = (listResponse.data as any[]) + .filter((r: any) => isStableRelease(r)) .map((release: { html_url: string; tag_name: string; body: string }) => toRelease(release)) - .filter(release => isNewerVersion(release.version, currentVersion)) + .filter((release: Release) => isNewerVersion(release.version, currentVersion)) .sort(sortByVersionDesc); logger.info('fetched releases from github', { count: releases.length }); diff --git a/src/backend/application/services/DownloadService.ts b/src/backend/application/services/DownloadService.ts new file mode 100644 index 00000000..74b7308a --- /dev/null +++ b/src/backend/application/services/DownloadService.ts @@ -0,0 +1,6 @@ +import { DownloadMetadata } from '@/backend/application/ports/gateways/media/DownloadGateway'; + +export default interface DownloadService { + getMetadata(url: string): Promise; + startDownload(url: string, savePath?: string): Promise; +} diff --git a/src/backend/application/services/FfmpegService.ts b/src/backend/application/services/FfmpegService.ts index 99bfabb3..cf962360 100644 --- a/src/backend/application/services/FfmpegService.ts +++ b/src/backend/application/services/FfmpegService.ts @@ -106,7 +106,12 @@ export default interface FfmpegService { /** * Convert audio file to WAV format */ - convertToWav(inputPath: string, outputPath: string): Promise; + convertToWav(args: { + taskId?: number, + inputPath: string, + outputPath: string, + onProgress?: (progress: number) => void + }): Promise; /** * NEW: Trim audio by time range (re-encode to mp3 for compatibility) diff --git a/src/backend/application/services/TtsService.ts b/src/backend/application/services/TtsService.ts index 304b73b4..d46619d5 100644 --- a/src/backend/application/services/TtsService.ts +++ b/src/backend/application/services/TtsService.ts @@ -20,7 +20,7 @@ class TtsService { if (StrUtil.isBlank(storeGet('apiKeys.openAi.key')) || StrUtil.isBlank(storeGet('apiKeys.openAi.endpoint'))) { throw new Error('OpenAI API key or endpoint is not set'); } - const url = this.joinUrl(storeGet('apiKeys.openAi.endpoint'), '/v1/audio/speech'); + const url = this.joinUrl(storeGet('apiKeys.openAi.endpoint').replace(/\/v1$/, ''), '/v1/audio/speech'); const headers = { 'Authorization': `Bearer ${storeGet('apiKeys.openAi.key')}`, 'User-Agent': 'Apifox/1.0.0 (https://apifox.com)', diff --git a/src/backend/application/services/VocabularyService.ts b/src/backend/application/services/VocabularyService.ts index 4160dd36..5d9a7826 100644 --- a/src/backend/application/services/VocabularyService.ts +++ b/src/backend/application/services/VocabularyService.ts @@ -24,4 +24,24 @@ export default interface VocabularyService { getAllWords(params: GetAllWordsParams): Promise; exportTemplate(): Promise; importWords(filePath: string): Promise; + /** + * 将单词加入收藏生词本。 + * + * @param params.word 单词原文(建议已归一化)。 + * @param params.translate 可选释义。 + * @returns 操作结果。 + */ + addWord(params: { word: string; translate?: string }): Promise<{ success: boolean; message?: string }>; + + /** + * 从生词本中删除选定单词。 + * @param word 单词原文。 + */ + deleteWord(word: string): Promise<{ success: boolean; message?: string }>; + + /** + * 重新获取并刷新指定单词的翻译。 + * @param word 单词原文。 + */ + refreshWordTranslation(word: string): Promise<{ success: boolean; translate?: string; message?: string }>; } diff --git a/src/backend/application/services/impl/DownloadServiceImpl.ts b/src/backend/application/services/impl/DownloadServiceImpl.ts new file mode 100644 index 00000000..c2a7ac61 --- /dev/null +++ b/src/backend/application/services/impl/DownloadServiceImpl.ts @@ -0,0 +1,69 @@ +import { inject, injectable } from 'inversify'; +import DownloadService from '@/backend/application/services/DownloadService'; +import DownloadGateway, { DownloadMetadata } from '@/backend/application/ports/gateways/media/DownloadGateway'; +import DpTaskService from '@/backend/application/services/DpTaskService'; +import TYPES from '@/backend/ioc/types'; +import StorageDirectoryProvider, { StorageDirectoryTarget } from '@/backend/application/ports/gateways/storage/StorageDirectoryProvider'; +import path from 'path'; +import fs from 'fs'; +import { getMainLogger } from '@/backend/infrastructure/logger'; + +@injectable() +export default class DownloadServiceImpl implements DownloadService { + private logger = getMainLogger('DownloadServiceImpl'); + + constructor( + @inject(TYPES.DownloadGateway) private downloadGateway: DownloadGateway, + @inject(TYPES.DpTaskService) private dpTaskService: DpTaskService, + @inject(TYPES.StorageDirectoryProvider) private storageDirectoryProvider: StorageDirectoryProvider, + ) {} + + public async getMetadata(url: string): Promise { + return this.downloadGateway.getMetadata(url); + } + + public async startDownload(url: string, savePath?: string): Promise { + const taskId = await this.dpTaskService.create(); + const metadata = await this.getMetadata(url); + + const libraryPath = await this.storageDirectoryProvider.provideDirectory(StorageDirectoryTarget.VIDEOS); + const safeTitle = metadata.title.replace(/[\\/:*?"<>|]/g, '_'); + const finalSavePath = savePath || path.join(libraryPath, `${safeTitle}.mp4`); + + // Ensure directory exists + const dir = path.dirname(finalSavePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + this.downloadGateway.download({ + taskId, + url, + savePath: finalSavePath, + onProgress: (percent, speed, eta) => { + this.dpTaskService.process(taskId, { + progress: `下载中: ${percent}% ${speed ? `(${speed})` : ''} ${eta ? `ETA: ${eta}` : ''}`, + result: JSON.stringify({ percent, speed, eta, path: finalSavePath }) + }); + }, + onCancelable: (cancel) => { + this.dpTaskService.registerTask(taskId, { + cancel: () => cancel() + }); + } + }).then(() => { + this.dpTaskService.finish(taskId, { + progress: '下载完成', + result: JSON.stringify({ percent: 100, path: finalSavePath }) + }); + }).catch((err) => { + this.logger.error('download failed', { error: err.message }); + this.dpTaskService.fail(taskId, { + progress: `下载失败: ${err.message}`, + result: JSON.stringify({ error: err.message }) + }); + }); + + return taskId; + } +} diff --git a/src/backend/application/services/impl/DpTaskServiceImpl.ts b/src/backend/application/services/impl/DpTaskServiceImpl.ts index d27dae95..56e0233a 100644 --- a/src/backend/application/services/impl/DpTaskServiceImpl.ts +++ b/src/backend/application/services/impl/DpTaskServiceImpl.ts @@ -1,6 +1,7 @@ import { getMainLogger } from '@/backend/infrastructure/logger'; import { DpTask, DpTaskState, InsertDpTask } from '@/backend/infrastructure/db/tables/dpTask'; +// @ts-ignore import { LRUCache } from 'lru-cache'; import TimeUtil from '@/common/utils/TimeUtil'; import {inject, injectable, postConstruct} from 'inversify'; diff --git a/src/backend/application/services/impl/FfmpegServiceImpl.ts b/src/backend/application/services/impl/FfmpegServiceImpl.ts index 472ad8a6..b33baa47 100644 --- a/src/backend/application/services/impl/FfmpegServiceImpl.ts +++ b/src/backend/application/services/impl/FfmpegServiceImpl.ts @@ -337,15 +337,36 @@ export default class FfmpegServiceImpl implements FfmpegService { * 转换音频文件为 WAV 格式(强制 16kHz、单声道、16-bit PCM)。 */ @WithSemaphore('ffmpeg') - public async convertToWav(inputPath: string, outputPath: string): Promise { + public async convertToWav(args: { + taskId?: number, + inputPath: string, + outputPath: string, + onProgress?: (progress: number) => void + }): Promise { + const { taskId, inputPath, outputPath, onProgress } = args; await this.storageDirectoryProvider.ensurePathAccessPermissionIfExists(inputPath); await this.storageDirectoryProvider.ensurePathAccessPermissionIfExists(outputPath); - await this.ffmpegGateway.convertToWav({ - inputFile: inputPath, - outputFile: outputPath, - sampleRate: 16000, - channels: 1, - }); + const inputDurationSecond = await this.duration(inputPath); + + await this.runCancelableTask( + taskId, + { + inputDurationSecond, + onProgress, + }, + async (onCancelable) => { + await this.ffmpegGateway.convertToWav({ + inputFile: inputPath, + outputFile: outputPath, + sampleRate: 16000, + channels: 1, + }, { + inputDurationSecond, + onProgress, + onCancelable, + }); + }, + ); } /** diff --git a/src/backend/application/services/impl/LocalTranscriptionServiceImpl.ts b/src/backend/application/services/impl/LocalTranscriptionServiceImpl.ts index 84bd43cf..69661280 100644 --- a/src/backend/application/services/impl/LocalTranscriptionServiceImpl.ts +++ b/src/backend/application/services/impl/LocalTranscriptionServiceImpl.ts @@ -21,6 +21,8 @@ import StorageDirectoryProvider, { export class LocalTranscriptionServiceImpl implements TranscriptionService { // 当前正在处理的文件路径 private activeFilePath: string | null = null; + // 当前正在执行的底层任务 ID (如 FFmpeg 或 Whisper) + private activeTaskId: number | null = null; // 被请求取消的文件集合 private cancelled = new Set(); @@ -41,6 +43,7 @@ export class LocalTranscriptionServiceImpl implements TranscriptionService { @inject(TYPES.RendererGateway) private rendererGateway: RendererGateway, @inject(TYPES.WhisperGateway) private whisperGateway: WhisperGateway, @inject(TYPES.StorageDirectoryProvider) private storageDirectoryProvider: StorageDirectoryProvider, + @inject(TYPES.DpTaskService) private dpTaskService: any, // 临时注入以便取消底层任务 ) {} /** @@ -48,10 +51,21 @@ export class LocalTranscriptionServiceImpl implements TranscriptionService { * @param inputPath 原始输入文件。 * @returns 转换后的 WAV 文件路径。 */ - private async ensureWavFormat(inputPath: string): Promise { + /** + * 确保输入音频转换为 WAV。 + * @param inputPath 原始输入文件。 + * @param onProgress 进度回调。 + * @returns 转换后的 WAV 文件路径。 + */ + private async ensureWavFormat(inputPath: string, taskId?: number, onProgress?: (progress: number) => void): Promise { const tempDir = await this.storageDirectoryProvider.provideDirectory(StorageDirectoryTarget.TEMP); const out = path.join(tempDir, `converted_${Date.now()}_${Math.random().toString(36).slice(2)}.wav`); - await this.ffmpegService.convertToWav(inputPath, out); + await this.ffmpegService.convertToWav({ + taskId, + inputPath, + outputPath: out, + onProgress + }); return out; } @@ -145,7 +159,14 @@ export class LocalTranscriptionServiceImpl implements TranscriptionService { // 预处理 if (this.isCancelled(filePath)) throw new Error('Transcription cancelled by user'); this.sendProgress(0, filePath, DpTaskState.IN_PROGRESS, 5, { message: '音频预处理(转换为 16k WAV)...' }); - processedAudioPath = await this.ensureWavFormat(filePath); + + // 将 FFmpeg 的 0-100 进度映射到总进度的 5-10% + this.activeTaskId = Math.floor(Math.random() * 10000) + 10000; + processedAudioPath = await this.ensureWavFormat(filePath, this.activeTaskId, (p) => { + const mapped = Math.floor(5 + (p * 0.05)); + this.sendProgress(0, filePath, DpTaskState.IN_PROGRESS, mapped, { message: '音频预处理(转换为 16k WAV)...' }); + }); + this.activeTaskId = null; // 引擎选择(按配置,不做兜底) const transcriptionEngine = await this.settingService.getCurrentTranscriptionProvider(); @@ -181,6 +202,7 @@ export class LocalTranscriptionServiceImpl implements TranscriptionService { throw error; } finally { this.activeFilePath = null; + this.activeTaskId = null; // 清理临时文件 try { @@ -281,9 +303,18 @@ export class LocalTranscriptionServiceImpl implements TranscriptionService { return true; } - // 如果是当前任务,doTranscribe 会在检查点自行退出 + // 如果是当前任务,尝试取消底层任务 if (this.activeFilePath === filePath) { this.whisperGateway.cancelActive(); + if (this.activeTaskId !== null) { + try { + // 调用底层 DpTaskService 取消可能的 FFmpeg 任务 + const task = (this as any).dpTaskService.getTask(this.activeTaskId); + if (task) task.cancel(); + } catch { + // + } + } return true; } diff --git a/src/backend/application/services/impl/OpenAIServiceImpl.ts b/src/backend/application/services/impl/OpenAIServiceImpl.ts index 8837bbd8..228d2561 100644 --- a/src/backend/application/services/impl/OpenAIServiceImpl.ts +++ b/src/backend/application/services/impl/OpenAIServiceImpl.ts @@ -23,7 +23,7 @@ export class OpenAIServiceImpl implements OpenAiService { this.apiKey = ak; this.endpoint = ep; this.openai = new OpenAI({ - baseURL: ep + '/v1', + baseURL: ep.replace(/\/+$/, '') + '/v1', apiKey: ak }); return this.openai; diff --git a/src/backend/application/services/impl/VideoLearningServiceImpl.ts b/src/backend/application/services/impl/VideoLearningServiceImpl.ts index 51776d5e..bbb25d45 100644 --- a/src/backend/application/services/impl/VideoLearningServiceImpl.ts +++ b/src/backend/application/services/impl/VideoLearningServiceImpl.ts @@ -455,7 +455,7 @@ export default class VideoLearningServiceImpl implements VideoLearningService { } if (options?.onProgress) { - const progress = Math.min(99, Math.round((completedChunks / totalChunks) * 100)); + const progress = Math.min(90, Math.round((completedChunks / totalChunks) * 90)); this.clipAnalysisProgress.set(analysisKey, progress); await options.onProgress(progress); } @@ -479,7 +479,7 @@ export default class VideoLearningServiceImpl implements VideoLearningService { } completedChunks++; - const progress = Math.min(99, Math.round((completedChunks / totalChunks) * 100)); + const progress = Math.min(90, Math.round((completedChunks / totalChunks) * 90)); if (options?.onProgress) { this.clipAnalysisProgress.set(analysisKey, progress); await options.onProgress(progress); @@ -495,28 +495,34 @@ export default class VideoLearningServiceImpl implements VideoLearningService { throw new Error('ANALYSIS_CANCELLED'); } const matches = matchResults[i] || []; - if (!matches || matches.length === 0) { - continue; + if (matches && matches.length > 0) { + const matchedWords = Array.from( + new Set( + matches + .map((m) => (m.databaseWord?.word || m.normalized || m.original || '').toLowerCase()) + .filter(Boolean) + ) + ); + + if (matchedWords.length > 0) { + const clipKey = this.mapToClipKey(srtKey, i); + candidates.push({ + indexInSrt: i, + clipKey, + matchedWords, + }); + } } - const matchedWords = Array.from( - new Set( - matches - .map((m) => (m.databaseWord?.word || m.normalized || m.original || '').toLowerCase()) - .filter(Boolean) - ) - ); - - if (matchedWords.length === 0) { - continue; + // 第二阶段进度:90% - 99% + if (options?.onProgress && i % 100 === 0) { + const collectionProgress = 90 + Math.round((i / srtLines.length) * 9); + if (collectionProgress !== (this.clipAnalysisProgress.get(analysisKey) ?? 0)) { + this.clipAnalysisProgress.set(analysisKey, collectionProgress); + await options.onProgress(collectionProgress); + } + await this.analysisScheduler.yieldIfNeeded(); } - - const clipKey = this.mapToClipKey(srtKey, i); - candidates.push({ - indexInSrt: i, - clipKey, - matchedWords, - }); } if (options?.onProgress) { diff --git a/src/backend/application/services/impl/VocabularyServiceImpl.ts b/src/backend/application/services/impl/VocabularyServiceImpl.ts index cb74480a..ced3158b 100644 --- a/src/backend/application/services/impl/VocabularyServiceImpl.ts +++ b/src/backend/application/services/impl/VocabularyServiceImpl.ts @@ -8,7 +8,8 @@ import { WordMatchService } from '@/backend/application/services/WordMatchServic import { getMainLogger } from '@/backend/infrastructure/logger'; import WordsRepository from '@/backend/application/ports/repositories/WordsRepository'; import StorageDirectoryProvider from '@/backend/application/ports/gateways/storage/StorageDirectoryProvider'; -import { loadDefaultVocabulary } from '@/backend/utils/defaultVocabulary'; +import TranslateService from '@/backend/application/services/AiTransServiceImpl'; +import { OpenAIDictionaryResult, YdRes } from '@/common/types/YdRes'; /** * 单词导入导出服务实现。 @@ -28,6 +29,9 @@ export default class VocabularyServiceImpl implements VocabularyService { @inject(TYPES.StorageDirectoryProvider) private storageDirectoryProvider!: StorageDirectoryProvider; + @inject(TYPES.TranslateService) + private translateService!: TranslateService; + private readonly logger = getMainLogger('VocabularyServiceImpl'); /** @@ -50,39 +54,6 @@ export default class VocabularyServiceImpl implements VocabularyService { return worksheet; } - /** - * 为默认词表工作表补充恢复说明区域。 - * - * 行为说明: - * - 说明放在右侧独立单元格,不遮挡词表正文。 - * - 单元格正文只显示简短标题,详细说明通过批注展示。 - * - * @param worksheet 默认词表工作表。 - */ - private addDefaultVocabularyRestoreNote(worksheet: XLSX.WorkSheet): void { - worksheet.D1 = { - t: 's', - v: '恢复说明', - c: [ - { - a: 'DashPlayer', - t: '如果想恢复默认词表,可以把这一页的内容复制到第一个工作表“单词管理”里,然后再导入。' - } - ] - }; - - const range = XLSX.utils.decode_range(worksheet['!ref'] ?? 'A1'); - range.e.c = Math.max(range.e.c, 3); - range.e.r = Math.max(range.e.r, 0); - worksheet['!ref'] = XLSX.utils.encode_range(range); - - const baseCols = worksheet['!cols'] ?? []; - baseCols[0] = baseCols[0] ?? { wch: 25 }; - baseCols[1] = baseCols[1] ?? { wch: 50 }; - baseCols[3] = baseCols[3] ?? { wch: 12 }; - worksheet['!cols'] = baseCols; - } - /** * 解析导入工作表并归一化为完整词表。 * @@ -149,8 +120,7 @@ export default class VocabularyServiceImpl implements VocabularyService { * 导出单词管理模板。 * * 行为说明: - * - 第一个工作表导出当前用户词表,作为后续导入的唯一数据源。 - * - 第二个工作表导出内置默认词表,便于用户恢复模板内容。 + * - 导出当前用户词表,作为后续导入的唯一数据源。 * * @returns Base64 编码的 Excel 文件内容。 */ @@ -164,21 +134,13 @@ export default class VocabularyServiceImpl implements VocabularyService { }; } - const defaultWords = await loadDefaultVocabulary(); - const currentVocabularyRows = wordsResult.data.map(word => ({ - 英文: word.word, - 释义: word.translate || '' - })); - const defaultVocabularyRows = defaultWords.map(word => ({ + const vocabularyRows = wordsResult.data.map(word => ({ 英文: word.word, 释义: word.translate || '' })); const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, this.createVocabularyWorksheet(currentVocabularyRows), '单词管理'); - const defaultWorksheet = this.createVocabularyWorksheet(defaultVocabularyRows); - this.addDefaultVocabularyRestoreNote(defaultWorksheet); - XLSX.utils.book_append_sheet(wb, defaultWorksheet, '默认词表'); + XLSX.utils.book_append_sheet(wb, this.createVocabularyWorksheet(vocabularyRows), '单词管理'); const excelBuffer = XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }); @@ -263,4 +225,77 @@ export default class VocabularyServiceImpl implements VocabularyService { }; } } + + async addWord(params: { word: string; translate?: string }): Promise<{ success: boolean; message?: string }> { + try { + const normalized = params.word.trim().toLowerCase(); + if (!normalized) { + return { success: false, message: '单词不能为空' }; + } + await this.wordsRepository.addWord(normalized, params.translate); + this.wordMatchService.invalidateVocabularyCache(); + return { success: true, message: `已将「${normalized}」加入生词本` }; + } catch (error) { + this.logger.error('添加单词失败', { error }); + return { success: false, message: error instanceof Error ? error.message : '添加失败' }; + } + } + + async deleteWord(word: string): Promise<{ success: boolean; message?: string }> { + try { + const normalized = word.trim().toLowerCase(); + await this.wordsRepository.deleteWord(normalized); + this.wordMatchService.invalidateVocabularyCache(); + return { success: true, message: `已从生词本中删除「${normalized}」` }; + } catch (error) { + this.logger.error('删除单词失败', { error }); + return { success: false, message: error instanceof Error ? error.message : '删除失败' }; + } + } + + async refreshWordTranslation(word: string): Promise<{ success: boolean; translate?: string; message?: string }> { + const normalized = word.trim().toLowerCase(); + try { + this.logger.info(`[VocabularyService] 开始刷新单词释义: ${normalized}`); + const result = await this.translateService.transWord(normalized, true); + + if (!result) { + this.logger.warn(`[VocabularyService] 词典查询未返回结果: ${normalized}`); + return { success: false, message: '未获取到词典释义' }; + } + + this.logger.debug(`[VocabularyService] 词典查询响应对象: ${normalized}`, { + hasTranslation: 'translation' in result, + hasDefinitions: 'definitions' in result + }); + + let formattedTranslate = ''; + // 兼容有道词典格式 + if ('translation' in (result as any) && Array.isArray((result as any).translation)) { + formattedTranslate = (result as YdRes).translation.join(';'); + } + // 兼容 OpenAI 字典格式 + else if ('definitions' in (result as any) && Array.isArray((result as any).definitions)) { + formattedTranslate = (result as OpenAIDictionaryResult).definitions + .map((d) => (d.partOfSpeech ? `[${d.partOfSpeech}] ${d.meaning}` : d.meaning)) + .join(';'); + } + + if (!formattedTranslate) { + this.logger.warn(`[VocabularyService] 无法从返回结果中解析出释义文本: ${normalized}`, { result }); + return { success: false, message: '无法解析获取到的释义内容' }; + } + + await this.wordsRepository.updateWord(normalized, formattedTranslate); + this.logger.info(`[VocabularyService] 单词释义更新成功: ${normalized}`); + + return { success: true, translate: formattedTranslate, message: '释义已刷新' }; + } catch (error) { + this.logger.error(`[VocabularyService] 刷新单词释义发生异常: ${normalized}`, { error }); + return { + success: false, + message: error instanceof Error ? `刷新请求失败: ${error.message}` : '连接字典服务失败,请重试' + }; + } + } } diff --git a/src/backend/application/services/impl/clients/AiProviderServiceImpl.ts b/src/backend/application/services/impl/clients/AiProviderServiceImpl.ts index 3395088e..a85caafc 100644 --- a/src/backend/application/services/impl/clients/AiProviderServiceImpl.ts +++ b/src/backend/application/services/impl/clients/AiProviderServiceImpl.ts @@ -25,7 +25,7 @@ export default class AiProviderServiceImpl implements AiProviderService { return null; } const openai = createOpenAI({ - baseURL: joinUrl(endpoint, '/v1'), + baseURL: joinUrl(endpoint.replace(/\/v1$/, ''), '/v1'), apiKey: apiKey, }); return openai(routedModel.modelId); diff --git a/src/backend/application/services/impl/clients/ModelRoutingServiceImpl.ts b/src/backend/application/services/impl/clients/ModelRoutingServiceImpl.ts index 9af3043e..a69838c6 100644 --- a/src/backend/application/services/impl/clients/ModelRoutingServiceImpl.ts +++ b/src/backend/application/services/impl/clients/ModelRoutingServiceImpl.ts @@ -6,7 +6,7 @@ import { SettingKey } from '@/common/types/store_schema'; @injectable() export default class ModelRoutingServiceImpl implements ModelRoutingService { - private static readonly DEFAULT_MODEL = 'gpt-5.4-nano'; + private static readonly DEFAULT_MODEL = 'gpt-4o'; private resolveFeatureKey(scene: AiModelScene): string { if (scene === 'sentenceLearning') { diff --git a/src/backend/infrastructure/db/repositories/FavouriteClipsRepositoryImpl.ts b/src/backend/infrastructure/db/repositories/FavouriteClipsRepositoryImpl.ts index 09c2dd58..ca83f35e 100644 --- a/src/backend/infrastructure/db/repositories/FavouriteClipsRepositoryImpl.ts +++ b/src/backend/infrastructure/db/repositories/FavouriteClipsRepositoryImpl.ts @@ -62,7 +62,8 @@ export default class FavouriteClipsRepositoryImpl implements FavouriteClipsRepos const tagIds: { tag_id: number | null }[] = tx .select({ tag_id: clipTagRelation.tag_id }) .from(clipTagRelation) - .where(eq(clipTagRelation.clip_key, clipKey)); + .where(eq(clipTagRelation.clip_key, clipKey)) + .all(); tx.delete(clipTagRelation).where(eq(clipTagRelation.clip_key, clipKey)); tx.delete(videoClip).where(eq(videoClip.key, clipKey)); @@ -74,7 +75,8 @@ export default class FavouriteClipsRepositoryImpl implements FavouriteClipsRepos const r = tx .select({ c: count() }) .from(clipTagRelation) - .where(eq(clipTagRelation.tag_id, tag_id)); + .where(eq(clipTagRelation.tag_id, tag_id)) + .all(); if ((r[0]?.c ?? 0) === 0) { tx.delete(tag).where(eq(tag.id, tag_id)); } @@ -220,7 +222,8 @@ export default class FavouriteClipsRepositoryImpl implements FavouriteClipsRepos const r = tx .select({ c: count() }) .from(clipTagRelation) - .where(eq(clipTagRelation.tag_id, tagId)); + .where(eq(clipTagRelation.tag_id, tagId)) + .all(); if ((r[0]?.c ?? 0) === 0) { tx.delete(tag).where(eq(tag.id, tagId)); } diff --git a/src/backend/infrastructure/db/repositories/VideoLearningClipRepositoryImpl.ts b/src/backend/infrastructure/db/repositories/VideoLearningClipRepositoryImpl.ts index e1590259..33c9a36a 100644 --- a/src/backend/infrastructure/db/repositories/VideoLearningClipRepositoryImpl.ts +++ b/src/backend/infrastructure/db/repositories/VideoLearningClipRepositoryImpl.ts @@ -14,12 +14,20 @@ export default class VideoLearningClipRepositoryImpl implements VideoLearningCli return new Set(); } - const rows = await db - .select({ key: videoLearningClip.key }) - .from(videoLearningClip) - .where(inArray(videoLearningClip.key, keys)); + const BATCH_SIZE = 500; + const result = new Set(); + + for (let i = 0; i < keys.length; i += BATCH_SIZE) { + const chunk = keys.slice(i, i + BATCH_SIZE); + const rows = await db + .select({ key: videoLearningClip.key }) + .from(videoLearningClip) + .where(inArray(videoLearningClip.key, chunk)); + + rows.forEach(r => result.add(r.key)); + } - return new Set(rows.map((r) => r.key)); + return result; } public async count(query: VideoLearningClipCountQuery = {}): Promise { diff --git a/src/backend/infrastructure/db/repositories/WordsRepositoryImpl.ts b/src/backend/infrastructure/db/repositories/WordsRepositoryImpl.ts index 328390b8..204dcf89 100644 --- a/src/backend/infrastructure/db/repositories/WordsRepositoryImpl.ts +++ b/src/backend/infrastructure/db/repositories/WordsRepositoryImpl.ts @@ -1,4 +1,4 @@ -import { like, or } from 'drizzle-orm'; +import { like, or, eq } from 'drizzle-orm'; import { injectable } from 'inversify'; import db from '@/backend/infrastructure/db'; @@ -44,4 +44,28 @@ export default class WordsRepositoryImpl implements WordsRepository { tx.insert(words).values(values).run(); }); } + + public async addWord(word: string, translate?: string): Promise { + await db + .insert(words) + .values({ + word: word.trim().toLowerCase(), + translate: translate?.trim() || null, + }) + .onConflictDoNothing(); + } + + public async updateWord(word: string, translate: string): Promise { + await db + .update(words) + .set({ + translate: translate.trim(), + updated_at: new Date().toISOString(), + }) + .where(eq(words.word, word.trim().toLowerCase())); + } + + public async deleteWord(word: string): Promise { + await db.delete(words).where(eq(words.word, word.trim().toLowerCase())); + } } diff --git a/src/backend/infrastructure/media/download/YtDlpGatewayImpl.ts b/src/backend/infrastructure/media/download/YtDlpGatewayImpl.ts new file mode 100644 index 00000000..47e31901 --- /dev/null +++ b/src/backend/infrastructure/media/download/YtDlpGatewayImpl.ts @@ -0,0 +1,111 @@ +import { injectable } from 'inversify'; +import { spawn } from 'child_process'; +import path from 'path'; +import fs from 'fs'; +import DownloadGateway, { DownloadMetadata, DownloadOptions } from '@/backend/application/ports/gateways/media/DownloadGateway'; +import { getRuntimeResourcePath } from '@/backend/utils/runtimeEnv'; +import { getMainLogger } from '@/backend/infrastructure/logger'; + +@injectable() +export default class YtDlpGatewayImpl implements DownloadGateway { + private logger = getMainLogger('YtDlpGatewayImpl'); + + private getBinaryPath(): string { + const ext = process.platform === 'win32' ? '.exe' : ''; + return getRuntimeResourcePath('lib', `yt-dlp${ext}`); + } + + public async getMetadata(url: string): Promise { + const binary = this.getBinaryPath(); + if (!fs.existsSync(binary)) { + throw new Error(`yt-dlp binary not found at ${binary}`); + } + + return new Promise((resolve, reject) => { + const child = spawn(binary, ['-J', url]); + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + child.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + child.on('close', (code) => { + if (code === 0) { + try { + const json = JSON.parse(stdout); + resolve({ + title: json.title || 'Unknown Title', + thumbnail: json.thumbnail, + url: url, + duration: json.duration, + }); + } catch (e) { + reject(new Error(`Failed to parse yt-dlp output: ${e}`)); + } + } else { + reject(new Error(`yt-dlp exited with code ${code}: ${stderr}`)); + } + }); + }); + } + + public async download(options: DownloadOptions): Promise { + const binary = this.getBinaryPath(); + if (!fs.existsSync(binary)) { + throw new Error(`yt-dlp binary not found at ${binary}`); + } + + const args = [ + '--newline', + '--progress', + '--format', 'bestvideo+bestaudio/best', + '--merge-output-format', 'mp4', + '-o', options.savePath, + options.url, + ]; + + return new Promise((resolve, reject) => { + const child = spawn(binary, args); + + options.onCancelable?.(() => { + try { + child.kill('SIGKILL'); + } catch (e) { + this.logger.warn(`Failed to kill yt-dlp process: ${e}`); + } + reject(new Error('Cancelled by user')); + }); + + child.stdout.on('data', (data) => { + const line = data.toString().trim(); + // [download] 10.0% of 100.00MiB at 10.00MiB/s ETA 00:01 + const progressMatch = line.match(/\[download\]\s+([\d.]+)% of\s+[\d.]+(?:MiB|GiB|kiB|B)\s+at\s+([\w\d./]+)\s+ETA\s+([\d:]+)/); + if (progressMatch) { + const percent = parseFloat(progressMatch[1]); + const speed = progressMatch[2]; + const eta = progressMatch[3]; + options.onProgress?.(percent, speed, eta); + } + }); + + child.stderr.on('data', (data) => { + this.logger.warn(`yt-dlp stderr: ${data.toString()}`); + }); + + child.on('close', (code) => { + if (code === 0) { + resolve(); + } else if (code === null || code === 9) { // SIGKILL returns null code or 9 + // Ignore, handled by onCancelable reject + } else { + reject(new Error(`yt-dlp download failed with code ${code}`)); + } + }); + }); + } +} diff --git a/src/backend/infrastructure/media/ffmpeg/FfmpegGatewayImpl.ts b/src/backend/infrastructure/media/ffmpeg/FfmpegGatewayImpl.ts index 729deb5c..c383527f 100644 --- a/src/backend/infrastructure/media/ffmpeg/FfmpegGatewayImpl.ts +++ b/src/backend/infrastructure/media/ffmpeg/FfmpegGatewayImpl.ts @@ -85,7 +85,7 @@ export default class FfmpegGatewayImpl implements FfmpegGateway { public async duration(filePath: string): Promise { return new Promise((resolve, reject) => { ffmpeg.ffprobe(filePath, (err, metadata) => { - if (err) reject(err); + if (err) reject(this.normalizeFfprobeError(err)); else resolve(metadata.format.duration ?? 0); }); }); @@ -98,7 +98,7 @@ export default class FfmpegGatewayImpl implements FfmpegGateway { const stats = await fs.promises.stat(filePath); const probeData = await new Promise((resolve, reject) => { ffmpeg.ffprobe(filePath, (err, metadata) => { - if (err) reject(err); + if (err) reject(this.normalizeFfprobeError(err)); else resolve(metadata as FfprobeData); }); }); @@ -254,4 +254,15 @@ export default class FfmpegGatewayImpl implements FfmpegGateway { await runningTask.result; } + + /** + * 标准化 ffprobe 错误,识别损坏或不完整的媒体文件。 + */ + private normalizeFfprobeError(err: any): Error { + const message = err instanceof Error ? err.message : String(err); + if (message.includes('moov atom not found') || message.includes('Invalid data found when processing input')) { + return new Error('视频文件损坏或不完整 (moov atom not found)。如果是正在下载的文件,请在下载完成后再试。'); + } + return err instanceof Error ? err : new Error(message); + } } diff --git a/src/backend/infrastructure/media/whisper/WhisperCppArgsBuilder.ts b/src/backend/infrastructure/media/whisper/WhisperCppArgsBuilder.ts index edf3ccbd..cde85e21 100644 --- a/src/backend/infrastructure/media/whisper/WhisperCppArgsBuilder.ts +++ b/src/backend/infrastructure/media/whisper/WhisperCppArgsBuilder.ts @@ -38,7 +38,9 @@ export class WhisperCppArgsBuilder { } else if (supportsVadModelFlag) { vadModelPath = path.join(modelsRoot, 'whisper-vad', `ggml-${vadModel}.bin`); if (!fs.existsSync(vadModelPath)) { - throw new Error(`静音检测模型未下载。请在【设置 → 服务配置 → Whisper 本地字幕识别】中下载静音检测模型后再转录。`); + // 如果 VAD 模型不存在,记录日志并回退到普通模式,而不是抛出异常 + vadModelPath = null; + vadSkippedBecauseUnsupported = true; } } } diff --git a/src/backend/infrastructure/media/whisper/WhisperCppCli.ts b/src/backend/infrastructure/media/whisper/WhisperCppCli.ts index a69c8b05..7df0535a 100644 --- a/src/backend/infrastructure/media/whisper/WhisperCppCli.ts +++ b/src/backend/infrastructure/media/whisper/WhisperCppCli.ts @@ -146,7 +146,13 @@ export class WhisperCppCli { child.on('close', (code) => { clearInterval(heartbeat); if (code === 0) resolve(); - else reject(new Error(`whisper.cpp exit code ${code}: ${stderr.slice(-2000)}`)); + else { + let errorMsg = `whisper.cpp exit code ${code}: ${stderr.slice(-2000)}`; + if (code !== 0 && !stderr.trim() && process.platform === 'win32') { + errorMsg += '\n提示:检测到程序异常退出且无输出,可能是由于缺少必要的 DLL 依赖或 Visual C++ 运行库。请尝试运行 "yarn download" 重新下载二进制文件,或安装最新的 VC++ Redistributable。'; + } + reject(new Error(errorMsg)); + } }); }); } finally { diff --git a/src/backend/ioc/inversify.config.ts b/src/backend/ioc/inversify.config.ts index 329fc5fa..15842b42 100644 --- a/src/backend/ioc/inversify.config.ts +++ b/src/backend/ioc/inversify.config.ts @@ -1,6 +1,11 @@ import { Container } from 'inversify'; import TYPES from './types'; import FavoriteClipsController from '@/backend/adapters/controllers/FavoriteClipsController'; +import DownloadGateway from '@/backend/application/ports/gateways/media/DownloadGateway'; +import YtDlpGatewayImpl from '@/backend/infrastructure/media/download/YtDlpGatewayImpl'; +import DownloadService from '@/backend/application/services/DownloadService'; +import DownloadServiceImpl from '@/backend/application/services/impl/DownloadServiceImpl'; +import DownloadController from '@/backend/adapters/controllers/DownloadController'; import Controller from '@/backend/adapters/controllers/Controller'; import TagService from '@/backend/application/services/TagService'; import TagController from '@/backend/adapters/controllers/TagController'; @@ -117,7 +122,7 @@ import SysConfRepository from '@/backend/application/ports/repositories/SysConfR import SysConfRepositoryImpl from '@/backend/infrastructure/db/repositories/SysConfRepositoryImpl'; import SubtitleTimestampAdjustmentsRepository from '@/backend/application/ports/repositories/SubtitleTimestampAdjustmentsRepository'; import SubtitleTimestampAdjustmentsRepositoryImpl from '@/backend/infrastructure/db/repositories/SubtitleTimestampAdjustmentsRepositoryImpl'; -import StorageDirectoryProvider from '@/backend/application/ports/gateways/storage/StorageDirectoryProvider'; +import StorageDirectoryProvider, { StorageDirectoryTarget } from '@/backend/application/ports/gateways/storage/StorageDirectoryProvider'; import StorageDirectoryProviderImpl from '@/backend/infrastructure/storage/StorageDirectoryProviderImpl'; @@ -148,6 +153,8 @@ container.bind(TYPES.Controller).to(SettingsController).inSingletonS container.bind(TYPES.Controller).to(WhisperModelController).inSingletonScope(); container.bind(TYPES.Controller).to(VocabularyController).inSingletonScope(); container.bind(TYPES.Controller).to(VideoLearningApiController).inSingletonScope(); +container.bind(TYPES.DownloadController).to(DownloadController).inSingletonScope(); +container.bind(TYPES.Controller).to(DownloadController).inSingletonScope(); // Services container.bind(TYPES.RendererGateway).to(RendererGatewayImpl).inSingletonScope(); container.bind(TYPES.RendererEvents).to(RendererEventsImpl).inSingletonScope(); @@ -195,4 +202,6 @@ container.bind(TYPES.CloudTranscriptionService).to(CloudTr container.bind(TYPES.LocalTranscriptionService).to(LocalTranscriptionServiceImpl).inSingletonScope(); container.bind(TYPES.WordMatchService).to(WordMatchServiceImpl).inSingletonScope(); container.bind(TYPES.VocabularyService).to(VocabularyServiceImpl).inSingletonScope(); +container.bind(TYPES.DownloadGateway).to(YtDlpGatewayImpl).inSingletonScope(); +container.bind(TYPES.DownloadService).to(DownloadServiceImpl).inSingletonScope(); export default container; diff --git a/src/backend/ioc/types.ts b/src/backend/ioc/types.ts index 25d250fb..249ce978 100644 --- a/src/backend/ioc/types.ts +++ b/src/backend/ioc/types.ts @@ -55,6 +55,9 @@ const TYPES = { ConfigStoreFactory: Symbol('ConfigStoreFactory'), SettingsStore: Symbol('SettingsStore'), SettingsKeyValueService: Symbol('SettingsKeyValueService'), + DownloadService: Symbol('DownloadService'), + DownloadGateway: Symbol('DownloadGateway'), + DownloadController: Symbol('DownloadController'), }; export default TYPES; diff --git a/src/backend/startup/customMigrations/migrations/storeSchemaProviderMigrationV1.ts b/src/backend/startup/customMigrations/migrations/storeSchemaProviderMigrationV1.ts index 509291ba..76c8e401 100644 --- a/src/backend/startup/customMigrations/migrations/storeSchemaProviderMigrationV1.ts +++ b/src/backend/startup/customMigrations/migrations/storeSchemaProviderMigrationV1.ts @@ -7,7 +7,7 @@ import { getEnvironmentConfigName } from '@/backend/utils/runtimeEnv'; import { storeSet } from '@/backend/infrastructure/settings/store'; import { OPENAI_SUBTITLE_CUSTOM_STYLE_KEY } from '@/common/constants/openaiSubtitlePrompts'; -const DEFAULT_OPENAI_MODEL = 'gpt-5.4-nano'; +const DEFAULT_OPENAI_MODEL = 'gpt-4o'; const MIGRATION_ID = 'store-schema-provider-v1'; const settingsStore = new Store>({ diff --git a/src/backend/startup/customMigrations/migrations/storeSchemaProviderMigrationV2.ts b/src/backend/startup/customMigrations/migrations/storeSchemaProviderMigrationV2.ts new file mode 100644 index 00000000..16d8ce64 --- /dev/null +++ b/src/backend/startup/customMigrations/migrations/storeSchemaProviderMigrationV2.ts @@ -0,0 +1,39 @@ +import Store from 'electron-store'; +import { getEnvironmentConfigName } from '@/backend/utils/runtimeEnv'; +import { storeSet } from '@/backend/infrastructure/settings/store'; + +const MIGRATION_ID = 'store-schema-provider-v2'; + +const settingsStore = new Store>({ + name: getEnvironmentConfigName('config'), +}); + +const getPersistedString = (key: string): string | null => { + const value = settingsStore.get(key); + if (typeof value !== 'string') { + return null; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +}; + +export const storeSchemaProviderMigrationV2 = { + id: MIGRATION_ID, + description: 'Sanitize invalid provider engines from store (e.g., groq) to prevent UI crash', + run: async (): Promise => { + const subtitle = getPersistedString('providers.subtitleTranslation'); + if (subtitle && !['openai', 'tencent', 'none'].includes(subtitle)) { + storeSet('providers.subtitleTranslation', 'none'); + } + + const dictionary = getPersistedString('providers.dictionary'); + if (dictionary && !['openai', 'youdao', 'none'].includes(dictionary)) { + storeSet('providers.dictionary', 'none'); + } + + const transcription = getPersistedString('providers.transcription'); + if (transcription && !['openai', 'whisper', 'none'].includes(transcription)) { + storeSet('providers.transcription', 'none'); + } + }, +}; diff --git a/src/backend/startup/customMigrations/runCustomMigrations.ts b/src/backend/startup/customMigrations/runCustomMigrations.ts index a7ea77c2..645df605 100644 --- a/src/backend/startup/customMigrations/runCustomMigrations.ts +++ b/src/backend/startup/customMigrations/runCustomMigrations.ts @@ -4,11 +4,13 @@ import { isDevelopmentMode } from '@/backend/utils/runtimeEnv'; import type { CustomMigration } from './types'; import { isCustomMigrationCompleted, markCustomMigrationCompleted } from './sysConfMarker'; import { storeSchemaProviderMigrationV1 } from './migrations/storeSchemaProviderMigrationV1'; +import { storeSchemaProviderMigrationV2 } from './migrations/storeSchemaProviderMigrationV2'; const logger = getMainLogger('custom-migrations'); const customMigrations: CustomMigration[] = [ storeSchemaProviderMigrationV1, + storeSchemaProviderMigrationV2, ]; const runCustomMigrations = async (): Promise => { diff --git a/src/common/api/api-def.ts b/src/common/api/api-def.ts index 6c3bde59..18aceeb5 100644 --- a/src/common/api/api-def.ts +++ b/src/common/api/api-def.ts @@ -204,6 +204,11 @@ interface ConvertDef { } +interface DownloadDef { + 'download/start': { params: { url: string; savePath?: string }, return: number }; + 'download/get-metadata': { params: string, return: { title: string; thumbnail?: string; url: string; duration?: number } }; +} + interface FavoriteClipsDef { 'favorite-clips/add': { params: { videoPath: string, srtKey: string, indexInSrt: number }, return: void }; 'favorite-clips/search': { params: ClipQuery, return: (ClipMeta & OssBaseMeta)[] }; @@ -238,6 +243,18 @@ interface VocabularyDef { params: { filePath: string }, return: { success: boolean; message?: string; error?: string } }; + 'vocabulary/add': { + params: { word: string; translate?: string }, + return: { success: boolean; message?: string } + }; + 'vocabulary/delete': { + params: { word: string }, + return: { success: boolean; message?: string } + }; + 'vocabulary/refresh-translation': { + params: { word: string }, + return: { success: boolean; translate?: string; message?: string } + }; } interface VideoLearningDef { @@ -299,7 +316,8 @@ export type ApiDefinitions = ApiDefinition & FavoriteClipsDef & TagDef & VocabularyDef - & VideoLearningDef; + & VideoLearningDef + & DownloadDef; // 更新 ApiMap 类型以使用 CombinedApiDefinitions export type ApiMap = { diff --git a/src/common/types/store_schema.ts b/src/common/types/store_schema.ts index 7cf9c937..9cd1ed44 100644 --- a/src/common/types/store_schema.ts +++ b/src/common/types/store_schema.ts @@ -34,10 +34,10 @@ export const SettingKeyObj = { 'features.openai.enableSentenceLearning': 'true', 'features.openai.subtitleTranslationMode': 'zh', 'features.openai.subtitleCustomStyle': '', - 'models.openai.available': 'gpt-5.4-nano', - 'models.openai.sentenceLearning': 'gpt-5.4-nano', - 'models.openai.subtitleTranslation': 'gpt-5.4-nano', - 'models.openai.dictionary': 'gpt-5.4-nano', + 'models.openai.available': 'gpt-4o', + 'models.openai.sentenceLearning': 'gpt-4o', + 'models.openai.subtitleTranslation': 'gpt-4o', + 'models.openai.dictionary': 'gpt-4o', 'subtitleTranslation.engine': 'openai', 'dictionary.engine': 'openai', 'transcription.engine': 'openai', @@ -54,5 +54,6 @@ export const SettingKeyObj = { 'player.autoPlayNext': 'false', 'storage.path': '', 'storage.collection': 'default', + 'appearance.sidebarOrder': '', } export type SettingKey = keyof typeof SettingKeyObj; diff --git a/src/common/utils/Util.ts b/src/common/utils/Util.ts index 067c7102..4d11006f 100644 --- a/src/common/utils/Util.ts +++ b/src/common/utils/Util.ts @@ -1,5 +1,5 @@ import { DpTask, DpTaskState } from '@/backend/infrastructure/db/tables/dpTask'; -import { Nullable } from 'vitest'; +export type Nullable = T | null; /** diff --git a/src/fronted/components/feature/file-browser/FileSelector.tsx b/src/fronted/components/feature/file-browser/FileSelector.tsx index 58da4703..fb88ded8 100644 --- a/src/fronted/components/feature/file-browser/FileSelector.tsx +++ b/src/fronted/components/feature/file-browser/FileSelector.tsx @@ -50,7 +50,7 @@ export class FileAction { 'opus', 'vorbis', ]); - const infos = await Promise.all(missing.map((p) => api.call('convert/video-info', p))); + const infos = await Promise.all(missing.map((p) => api.call('convert/video-info', p).catch(() => null))); const suspiciousFiles = missing.filter((_p, idx) => { const audioCodec = (infos[idx]?.audioCodec ?? '').toLowerCase(); return audioCodec.length === 0 || suspiciousAudioCodecs.has(audioCodec); diff --git a/src/fronted/components/feature/file-browser/ProjItem2.tsx b/src/fronted/components/feature/file-browser/ProjItem2.tsx index e9f11814..65840947 100644 --- a/src/fronted/components/feature/file-browser/ProjItem2.tsx +++ b/src/fronted/components/feature/file-browser/ProjItem2.tsx @@ -36,7 +36,7 @@ const ProjItem2 = ({ v, onClick, ctxMenus, variant = 'normal' }: { ctxMenus: CtxMenu[] }) => { const [contextMenu, setContextMenu] = React.useState(false); - const containerRef = React.useRef(null); + const containerRef = React.useRef(null); const inView = useInView(containerRef); const isFolder = v.isFolder; const isAudio = !isFolder && MediaUtil.isAudio(v?.fileName); diff --git a/src/fronted/components/feature/file-browser/VideoItem2.tsx b/src/fronted/components/feature/file-browser/VideoItem2.tsx index ab6aee9a..c634bda0 100644 --- a/src/fronted/components/feature/file-browser/VideoItem2.tsx +++ b/src/fronted/components/feature/file-browser/VideoItem2.tsx @@ -42,7 +42,7 @@ const VideoItem2 = ({ pv, variant = 'normal', ctxMenus, onClick }: { const [contextMenu, setContextMenu] = React.useState(false); const [thumbnailReady, setThumbnailReady] = React.useState(false); const [thumbnailError, setThumbnailError] = React.useState(false); - const containerRef = React.useRef(null); + const containerRef = React.useRef(null); const inView = useInView(containerRef); const isAudio = MediaUtil.isAudio(pv.fileName); const isVideo = MediaUtil.isVideo(pv.fileName); @@ -106,8 +106,8 @@ const VideoItem2 = ({ pv, variant = 'normal', ctxMenus, onClick }: {
)} 0; - const containerRef = React.useRef(null); + const containerRef = React.useRef(null); const inView = useInView(containerRef); // 2. 如果是 mp3,就不调用生成缩略图的接口,把 key 设为 null diff --git a/src/fronted/components/feature/file-browser/project-list-item.tsx b/src/fronted/components/feature/file-browser/project-list-item.tsx index ba899e31..0c93b5ab 100644 --- a/src/fronted/components/feature/file-browser/project-list-item.tsx +++ b/src/fronted/components/feature/file-browser/project-list-item.tsx @@ -30,7 +30,7 @@ const ProjectListItem = ({ video, onSelected }: { // 判断是否为 MP3 文件 const isAudio = MediaUtil.isAudio(video.fileName); const showDuration = !video.isFolder && video.duration > 0; - const containerRef = React.useRef(null); + const containerRef = React.useRef(null); const inView = useInView(containerRef); const { data: url } = useSWR( diff --git a/src/fronted/components/feature/player/translatable-line/openai-word-pop.tsx b/src/fronted/components/feature/player/translatable-line/openai-word-pop.tsx index 2acb51a2..9a4d8e64 100644 --- a/src/fronted/components/feature/player/translatable-line/openai-word-pop.tsx +++ b/src/fronted/components/feature/player/translatable-line/openai-word-pop.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { RefreshCw } from 'lucide-react'; +import { RefreshCw, Star } from 'lucide-react'; import { OpenAIDictionaryResult } from '@/common/types/YdRes'; import Playable from '@/fronted/components/shared/common/Playable'; import { cn } from '@/fronted/lib/utils'; @@ -23,13 +23,28 @@ interface OpenAIWordPopProps { isLoading?: boolean; isStreaming?: boolean; onRefresh?: () => void; - className?: string; // 新增:容器 class 覆盖 + /** 收藏按钮点击回调,参数为 (单词, 释义) */ + onAddToVocabulary?: (word: string, translate?: string) => void; + /** 当前单词是否已在生词本中 */ + isWordInVocabulary?: boolean; + className?: string; // 容器 class 覆盖 } -const OpenAIWordPop: React.FC = ({ data, isLoading = false, isStreaming = false, onRefresh, className }) => { +const OpenAIWordPop: React.FC = ({ data, isLoading = false, isStreaming = false, onRefresh, onAddToVocabulary, isWordInVocabulary = false, className }) => { const hasDefinitions = !!data && Array.isArray(data.definitions) && data.definitions.length > 0; const hasContent = !!data && (Boolean(data.word) || hasDefinitions); + const translateText = React.useMemo(() => { + if (!data || !hasDefinitions) return undefined; + return data.definitions + .slice(0, 3) + .map((definition) => { + const prefix = definition.partOfSpeech ? `${definition.partOfSpeech}. ` : ''; + return `${prefix}${definition.meaning}`; + }) + .join(';'); + }, [data, hasDefinitions]); + const renderSkeleton = () => (
{/* 单词标题骨架 */} @@ -166,19 +181,36 @@ const OpenAIWordPop: React.FC = ({ data, isLoading = false, return (
- {onRefresh && ( - - )} +
+ {onAddToVocabulary && ( + + )} + {onRefresh && ( + + )} +
{isLoading && !hasContent ? renderSkeleton() : renderContent()}
diff --git a/src/fronted/components/feature/player/translatable-line/translatable-theme.tsx b/src/fronted/components/feature/player/translatable-line/translatable-theme.tsx index 20e38a98..182f1dd2 100644 --- a/src/fronted/components/feature/player/translatable-line/translatable-theme.tsx +++ b/src/fronted/components/feature/player/translatable-line/translatable-theme.tsx @@ -36,8 +36,8 @@ const defaultTheme: TransLineTheme = { root: 'px-10 pt-2.5 pb-2.5 text-center leading-relaxed' }, word: { - hoverBgClass: 'transition-colors hover:bg-stone-100/90 dark:hover:bg-neutral-600/90', - vocabHighlightClass: 'font-medium text-sky-700/85 dark:text-sky-300/85 underline decoration-sky-500/45 dark:decoration-sky-300/45 decoration-[1.5px] underline-offset-[0.14em] rounded-sm transition-colors hover:bg-sky-500/10 dark:hover:bg-sky-400/10', + hoverBgClass: 'hover:bg-stone-100 dark:hover:bg-neutral-600', + vocabHighlightClass: '!text-blue-400 !underline !decoration-blue-400 !decoration-1 !bg-blue-500/10 px-0.5 rounded hover:!bg-blue-500/30', popReferenceBgClass: 'bg-stone-100 dark:bg-neutral-600' }, pop: { diff --git a/src/fronted/components/feature/player/translatable-line/word-pop.tsx b/src/fronted/components/feature/player/translatable-line/word-pop.tsx index 9aa38325..ca9e9a53 100644 --- a/src/fronted/components/feature/player/translatable-line/word-pop.tsx +++ b/src/fronted/components/feature/player/translatable-line/word-pop.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Star, RefreshCw } from 'lucide-react'; import { FloatingPortal, autoPlacement, @@ -16,6 +17,7 @@ import { useTransLineTheme } from './translatable-theme'; const logger = getRendererLogger('WordPop'); export interface WordSubParam { + word?: string; translation: YdRes | OpenAIDictionaryResult | null | undefined; /** * 浮层锚点所绑定的单词元素。 @@ -29,6 +31,10 @@ export interface WordSubParam { openaiStreamingData?: OpenAIDictionaryResult | null; isStreaming?: boolean; onRefresh?: () => void; + /** 收藏按钮点击回调,参数为 (单词, 释义) */ + onAddToVocabulary?: (word: string, translate?: string) => void; + /** 当前单词是否已在生词本中 */ + isWordInVocabulary?: boolean; classNames?: { container?: string; // youdao 容器覆盖 openaiContainer?: string; // openai 容器覆盖 @@ -37,14 +43,17 @@ export interface WordSubParam { } const WordPop = React.forwardRef( - ( + ( { + word, translation, referenceElement, isLoading: externalIsLoading, openaiStreamingData, isStreaming = false, onRefresh, + onAddToVocabulary, + isWordInVocabulary, classNames }: WordSubParam, ref: React.ForwardedRef @@ -111,8 +120,40 @@ const WordPop = React.forwardRef( />
)} -
- {ydData?.translation} +
+
+ {ydData?.translation?.join(';')} +
+
+ {onAddToVocabulary && word && ( + + )} + {onRefresh && ( + + )} +
); @@ -147,6 +188,8 @@ const WordPop = React.forwardRef( isLoading={openAILoading} isStreaming={isStreaming} onRefresh={onRefresh} + onAddToVocabulary={onAddToVocabulary} + isWordInVocabulary={isWordInVocabulary} /> ); } @@ -157,7 +200,8 @@ const WordPop = React.forwardRef( theme.pop.container, classNames?.container, isLoading ? 'opacity-0' : 'opacity-100', - shouldShowYoudao && (translation as any)?.webdict?.url && 'pt-4' + shouldShowYoudao && (translation as any)?.webdict?.url && 'pt-4', + 'relative overflow-hidden' )} > {shouldShowYoudao && renderYoudaoContent(translation as YdRes)} diff --git a/src/fronted/components/feature/player/translatable-line/word.tsx b/src/fronted/components/feature/player/translatable-line/word.tsx index 9ab916b1..5151f69a 100644 --- a/src/fronted/components/feature/player/translatable-line/word.tsx +++ b/src/fronted/components/feature/player/translatable-line/word.tsx @@ -1,5 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; +// @ts-ignore import * as turf from '@turf/turf'; +// @ts-ignore import { Feature, Polygon } from '@turf/turf'; import WordPop from './word-pop'; import { playUrl, playWord, getTtsUrl, playAudioUrl } from '@/common/utils/AudioPlayer'; @@ -16,9 +18,24 @@ import { usePlayer } from '@/fronted/hooks/usePlayer'; import useDictionaryStream, { createDictionaryRequestId } from '@/fronted/hooks/useDictionaryStream'; import useSetting from '@/fronted/hooks/useSetting'; import { backendClient } from '@/fronted/application/bootstrap/backendClient'; +import { useToast } from '@/fronted/components/ui/use-toast'; const api = backendClient; const logger = getRendererLogger('Word'); + +/** 从 OpenAIDictionaryResult 中提取简短释义(用于生词本) */ +const extractTranslate = (data: OpenAIDictionaryResult | null | undefined): string | undefined => { + if (!data || !Array.isArray(data.definitions) || data.definitions.length === 0) { + return undefined; + } + return data.definitions + .slice(0, 3) + .map((d) => { + const prefix = d.partOfSpeech ? `${d.partOfSpeech}. ` : ''; + return `${prefix}${d.meaning}`; + }) + .join(';'); +}; export interface WordParam { word: string; original: string; @@ -52,9 +69,10 @@ export const getBox = (ele: HTMLElement): Feature => { ], ]); }; -const Word = ({word, original, pop, requestPop, show, alwaysDark, classNames}: WordParam) => { +const Word = ({word, original, pop, requestPop, show, alwaysDark = false, classNames}: WordParam) => { const pause = usePlayer((s) => s.pause); const vocabularyStore = useVocabulary(); + const { toast } = useToast(); const [hovered, setHovered] = useState(false); const [playLoading, setPlayLoading] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); @@ -63,7 +81,7 @@ const Word = ({word, original, pop, requestPop, show, alwaysDark, classNames}: W // 检查是否是词汇单词 const cleanWord = word.toLowerCase().replace(/[^\w-]/g, ''); - const isVocabularyWord = cleanWord && vocabularyStore.isVocabularyWord(cleanWord); + const isVocabularyWord = !!cleanWord && vocabularyStore.isVocabularyWord(cleanWord); const hoverBg = classNames?.hover ?? (alwaysDark ? 'hover:bg-neutral-600' : theme.word.hoverBgClass); const vocabCls = isVocabularyWord ? (classNames?.vocab ?? theme.word.vocabHighlightClass) : undefined; @@ -102,7 +120,7 @@ const Word = ({word, original, pop, requestPop, show, alwaysDark, classNames}: W }); if (openaiDictionaryEnabled) { - const isOpenAIDictionary = !!result && typeof result === 'object' && 'definitions' in (result as Record); + const isOpenAIDictionary = !!result && typeof result === 'object' && 'definitions' in (result as unknown as Record); useDictionaryStream.getState().setFinalResult( targetWord, requestId, @@ -138,7 +156,7 @@ const Word = ({word, original, pop, requestPop, show, alwaysDark, classNames}: W }); if (openaiDictionaryEnabled) { - const isOpenAIDictionary = !!newData && typeof newData === 'object' && 'definitions' in (newData as Record); + const isOpenAIDictionary = !!newData && typeof newData === 'object' && 'definitions' in (newData as unknown as Record); useDictionaryStream.getState().setFinalResult( original, requestId, @@ -156,6 +174,23 @@ const Word = ({word, original, pop, requestPop, show, alwaysDark, classNames}: W setIsRefreshing(false); } }; + + /** 将当前单词加入收藏生词本 */ + const handleAddToVocabulary = async (wordToAdd: string, translate?: string) => { + const normalized = wordToAdd.trim().toLowerCase(); + try { + const result = await api.call('vocabulary/add', { word: normalized, translate }); + if (result.success) { + vocabularyStore.addVocabularyWords([normalized]); + toast({ title: '已收藏', description: `「${normalized}」已加入生词本` }); + } else { + toast({ title: '收藏失败', description: result.message, variant: 'destructive' }); + } + } catch (error) { + logger.error('收藏单词失败', { error: error instanceof Error ? error.message : error }); + toast({ title: '收藏失败', description: error instanceof Error ? error.message : String(error), variant: 'destructive' }); + } + }; const eleRef = useRef(null); const popperRef = useRef(null); const resquested = useRef(false); @@ -283,6 +318,7 @@ const Word = ({word, original, pop, requestPop, show, alwaysDark, classNames}: W {pop && hovered ? ( ) : null} @@ -298,7 +336,3 @@ const Word = ({word, original, pop, requestPop, show, alwaysDark, classNames}: W }; export default Word; - -Word.defaultProps = { - alwaysDark: false, -} diff --git a/src/fronted/components/layout/SideBar.tsx b/src/fronted/components/layout/SideBar.tsx index 4033cba7..3b957cb7 100644 --- a/src/fronted/components/layout/SideBar.tsx +++ b/src/fronted/components/layout/SideBar.tsx @@ -12,17 +12,23 @@ export interface SideBarProps { compact?: boolean; } +import { Reorder } from 'framer-motion'; +import { useNavigationStore } from '@/fronted/hooks/useNavigation'; +import { NavItem } from '@/fronted/config/navigation'; + const SideBar = ({ compact }: SideBarProps) => { const { t } = useI18nTranslation('nav'); const navigate = useNavigate(); const location = useLocation(); const videoId = useFile((s) => s.videoId); const theme = useSetting((s) => s.values.get('appearance.theme')); - const item = ( + const { orderedItems, setOrderedItems } = useNavigationStore(); + + const renderItem = ( text: string, path: string, key: string, - icon: ReactElement + icon: React.ReactNode ) => { const isPlayer = key === 'pa-player'; const isActive = isPlayer @@ -30,18 +36,18 @@ const SideBar = ({ compact }: SideBarProps) => { : location.pathname.includes(key); return (
navigate(path)} + onClick={() => navigate(path)} className={cn( - 'w-full px-2 flex justify-start items-center gap-2 rounded-xl h-10', + 'w-full px-2 flex justify-start items-center gap-2 rounded-xl h-10 select-none cursor-grab active:cursor-grabbing', isActive ? 'bg-zinc-100 drop-shadow dark:bg-neutral-900 shadow-white shadow-inner dark:shadow-none' : 'hover:bg-stone-300 dark:hover:bg-neutral-800', compact && 'justify-center' )} > - {cloneElement(icon, { + {React.isValidElement(icon) ? React.cloneElement(icon as React.ReactElement, { className: cn('w-5 h-5 text-yellow-600 text-yellow-500 flex-shrink-0') - })} + }) : icon} {!compact && (
{text} @@ -66,47 +72,33 @@ const SideBar = ({ compact }: SideBarProps) => { src={theme === 'dark' ? logoDark : logoLight} />
-
- {/* {item('Home', '/home', 'home', )} */} - {item( - t('playbackLab'), - `/player/${videoId}?sideBarAnimation=false`, - 'pa-player', -
+ + {orderedItems.map((navItem: NavItem) => { + const finalPath = navItem.id === 'pa-player' + ? `/player/${videoId}?sideBarAnimation=false` + : navItem.path; + + return ( + + {renderItem( + t(navItem.label), + finalPath, + navItem.id, + navItem.icon + )} + + ); + })} +
); }; diff --git a/src/fronted/components/ui/sidebar.tsx b/src/fronted/components/ui/sidebar.tsx index a4eaadd8..16b69afe 100644 --- a/src/fronted/components/ui/sidebar.tsx +++ b/src/fronted/components/ui/sidebar.tsx @@ -4,7 +4,7 @@ import { cva, type VariantProps } from "class-variance-authority" import { PanelLeft } from "lucide-react" import { useIsMobile } from "@/fronted/hooks/use-mobile" -import { cn } from "@/fronted/components/lib/utils" +import { cn } from "@/fronted/lib/utils" import { Button } from "@/fronted/components/ui/button" import { Input } from "@/fronted/components/ui/input" import { Separator } from "@/fronted/components/ui/separator" diff --git a/src/fronted/config/navigation.tsx b/src/fronted/config/navigation.tsx new file mode 100644 index 00000000..9421fb9f --- /dev/null +++ b/src/fronted/config/navigation.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { BookOpen, Captions, Download, Rotate3D, Settings, SquareSplitHorizontal, Star, User, Video } from 'lucide-react'; + +export interface NavItem { + id: string; + label: string; + path: string; + icon?: React.ReactNode; + homeOnly?: boolean; + sidebarOnly?: boolean; +} + +export const NAV_ITEMS: NavItem[] = [ + { + id: 'pa-player', + label: 'playbackLab', + path: '/player', // Path usually needs videoId suffix + icon: