From b8a3b043ee412f8b3ba024f9377d38c9482d6b21 Mon Sep 17 00:00:00 2001 From: zhengzequan Date: Thu, 22 Jan 2026 16:48:49 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E6=96=B0=E5=A2=9Eass=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/impl/WatchHistoryServiceImpl.ts | 39 ++++++- src/common/utils/AssUtil.ts | 100 ++++++++++++++++++ src/common/utils/MediaUtil.ts | 7 ++ 3 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 src/common/utils/AssUtil.ts diff --git a/src/backend/services/impl/WatchHistoryServiceImpl.ts b/src/backend/services/impl/WatchHistoryServiceImpl.ts index dc9b95bb..408d4cec 100644 --- a/src/backend/services/impl/WatchHistoryServiceImpl.ts +++ b/src/backend/services/impl/WatchHistoryServiceImpl.ts @@ -17,6 +17,7 @@ import MatchSrt from '@/backend/utils/MatchSrt'; import MediaUtil from '@/common/utils/MediaUtil'; import SystemService from '@/backend/services/SystemService'; import FileUtil from '@/backend/utils/FileUtil'; +import AssUtil from '@/common/utils/AssUtil'; @injectable() @@ -211,17 +212,47 @@ export default class WatchHistoryServiceImpl implements WatchHistoryService { if (!fs.existsSync(filePath)) { return null; } + let srtFile = history.srt_file; + + // 1. Check for existing and valid SRT file in history if (StrUtil.isNotBlank(srtFile)) { const exists = await FileUtil.fileExists(srtFile); if (!exists) { srtFile = ''; } } + + // 2. If no valid SRT in history, try to find a matching subtitle file if (StrUtil.isBlank(srtFile)) { + // First, try to find a matching .srt file const srtFiles = await this.listSrtFiles(base_path); - srtFile = MatchSrt.matchOne(filePath, srtFiles); + let subtitleFile = MatchSrt.matchOne(filePath, srtFiles); + + // If no .srt file is found, try to find and convert an .ass file + if (!subtitleFile) { + const assFiles = await this.listAssFiles(base_path); + const matchedAssFile = MatchSrt.matchOne(filePath, assFiles); + + if (matchedAssFile) { + try { + const assContent = await fs.promises.readFile(matchedAssFile, 'utf-8'); + const srtContent = AssUtil.assToSrt(assContent); + + const newSrtPath = matchedAssFile.replace(/\.ass$/i, '.srt'); + await fs.promises.writeFile(newSrtPath, srtContent); + + subtitleFile = newSrtPath; + // Persist this new association for future use + await this.attachSrt(filePath, subtitleFile); + } catch (e) { + console.error(`Failed to convert ASS file ${matchedAssFile} to SRT:`, e); + } + } + } + srtFile = subtitleFile; } + const duration = await this.mediaService.duration(filePath); return { id: history.id, @@ -243,6 +274,12 @@ export default class WatchHistoryServiceImpl implements WatchHistoryService { .map(file => path.join(folder, file)); } + private async listAssFiles(folder: string): Promise { + const files = await FileUtil.listFiles(folder); + return files.filter(file => MediaUtil.isAss(file)) + .map(file => path.join(folder, file)); + } + private async tryCreateFromFolder(folder: string): Promise { const ids: string[] = []; if (!fs.existsSync(folder) || !fs.statSync(folder).isDirectory()) { diff --git a/src/common/utils/AssUtil.ts b/src/common/utils/AssUtil.ts new file mode 100644 index 00000000..a9c9bdd0 --- /dev/null +++ b/src/common/utils/AssUtil.ts @@ -0,0 +1,100 @@ +import SrtUtil, { SrtLine } from '@/common/utils/SrtUtil'; +import StrUtil from '@/common/utils/str-util'; + +/** + * A simple utility for converting ASS subtitle format to SRT format. + * This utility focuses on extracting dialogue events and converting them + * to SRT-compatible lines, discarding all styling and complex features of ASS. + */ +export default class AssUtil { + /** + * Converts the content of an .ass file to an .srt file content string. + * @param assContent The full string content of the .ass file. + * @returns A string representing the content of an .srt file. + */ + public static assToSrt(assContent: string): string { + if (StrUtil.isBlank(assContent)) { + return ''; + } + + const lines = assContent.split(/\r?\n/); + let inEventsSection = false; + const srtLines: SrtLine[] = []; + let dialogueFormat: string[] = []; + let formatTextIndex = -1; + + for (const line of lines) { + const trimmedLine = line.trim(); + + if (trimmedLine.startsWith('[Events]')) { + inEventsSection = true; + continue; + } + + if (!inEventsSection || StrUtil.isBlank(trimmedLine)) { + continue; + } + + if (trimmedLine.startsWith('Format:')) { + dialogueFormat = trimmedLine.substring(7).trim().split(',').map(f => f.trim()); + formatTextIndex = dialogueFormat.findIndex(f => f.toLowerCase() === 'text'); + continue; + } + + if (trimmedLine.startsWith('Dialogue:')) { + if (formatTextIndex === -1) continue; // Skip if format not defined + + const parts = trimmedLine.substring(9).trim().split(','); + if (parts.length < formatTextIndex + 1) continue; // Skip malformed lines + + const startTime = this.assTimeToSrtSeconds(parts[dialogueFormat.indexOf('Start')]); + const endTime = this.assTimeToSrtSeconds(parts[dialogueFormat.indexOf('End')]); + const text = parts.slice(formatTextIndex).join(','); + + if (startTime === null || endTime === null || StrUtil.isBlank(text)) { + continue; + } + + // Remove ASS styling tags (e.g., {\b1}) + const cleanText = text.replace(/\{[^}]+\}/g, '').replace(/\\N/g, '\n').trim(); + + if (StrUtil.isNotBlank(cleanText)) { + srtLines.push(SrtUtil.createSrtLine( + srtLines.length + 1, + startTime, + endTime, + cleanText, + '' + )); + } + } + } + + return SrtUtil.srtLinesToSrt(srtLines, { reindex: true }); + } + + /** + * Converts ASS time format (H:MM:SS.ss) to seconds. + * @param timeString The time string from the ASS file. + * @returns The time in seconds, or null if parsing fails. + */ + private static assTimeToSrtSeconds(timeString: string | undefined): number | null { + if (!timeString) { + return null; + } + + const parts = timeString.split(':'); + if (parts.length !== 3) { + return null; + } + + try { + const hours = parseFloat(parts[0]); + const minutes = parseFloat(parts[1]); + const secondsAndCentiseconds = parseFloat(parts[2]); + return hours * 3600 + minutes * 60 + secondsAndCentiseconds; + } catch (e) { + return null; + } + } +} diff --git a/src/common/utils/MediaUtil.ts b/src/common/utils/MediaUtil.ts index 1165fa27..a9fa3758 100644 --- a/src/common/utils/MediaUtil.ts +++ b/src/common/utils/MediaUtil.ts @@ -16,6 +16,13 @@ export default class MediaUtil { return path.endsWith('.srt'); } + public static isAss(path: string): boolean { + if (StrUtil.isBlank(path)) { + return false; + } + return path.endsWith('.ass'); + } + public static supported(path: string): boolean { if (StrUtil.isBlank(path)) { return false; From 3c9e30103afd99606c03c7c3f428ea8d2d959025 Mon Sep 17 00:00:00 2001 From: zhengzequan Date: Fri, 23 Jan 2026 10:22:10 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat:=E6=94=AF=E6=8C=81=E5=8D=95=E8=AF=8D?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E3=80=81=E5=8D=95=E8=AF=8D=E7=BF=BB=E8=AF=91?= =?UTF-8?q?=E3=80=81=E5=8D=95=E8=AF=8D=E5=AE=9A=E4=BD=8D=E6=89=80=E5=9C=A8?= =?UTF-8?q?=E5=8F=A5=E5=AD=90=E5=88=97=E8=A1=A8=E3=80=81=E5=BF=AB=E6=8D=B7?= =?UTF-8?q?=E9=94=AE=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + .vscode/settings.json | 2 + src/backend/controllers/SubtitleController.ts | 46 +++++++ src/backend/services/CacheService.ts | 4 +- src/common/types/store_schema.ts | 1 + src/fronted/components/PlayerSrtLayout.tsx | 20 ++- .../components/short-cut/GlobalShortCut.tsx | 8 ++ src/fronted/components/word-list/WordList.tsx | 67 ++++++++++ .../components/word-list/WordListWord.tsx | 115 ++++++++++++++++++ src/fronted/hooks/useLayout.ts | 6 + src/fronted/pages/setting/ShortcutSetting.tsx | 10 +- 11 files changed, 276 insertions(+), 4 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 src/fronted/components/word-list/WordList.tsx create mode 100644 src/fronted/components/word-list/WordListWord.tsx diff --git a/.gitignore b/.gitignore index 833ec4f9..123948fb 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,4 @@ out/ lib/ /dist/ +.history \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..7a73a41b --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/src/backend/controllers/SubtitleController.ts b/src/backend/controllers/SubtitleController.ts index 5c421aa0..8b275d89 100644 --- a/src/backend/controllers/SubtitleController.ts +++ b/src/backend/controllers/SubtitleController.ts @@ -16,8 +16,54 @@ export default class SubtitleController implements Controller { return this.subtitleService.parseSrt(path); } + public async extractWords(path: string): Promise> { + const srtData = await this.subtitleService.parseSrt(path); + if (!srtData || !srtData.sentences) { + return {}; + } + + const wordMap: Record = {}; + + const stopWords = new Set([ + 'a', 'an', 'the', 'i', 'me', 'my', 'myself', 'we', 'our', 'ours', 'ourselves', 'you', 'your', 'yours', 'he', 'him', 'his', + 'she', 'her', 'hers', 'it', 'its', 'they', 'them', 'their', 'what', 'which', 'who', 'whom', 'this', 'that', 'these', + 'those', 'am', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'having', 'do', 'does', 'did', + 'doing', 'a', 'an', 'the', 'and', 'but', 'if', 'or', 'because', 'as', 'until', 'while', 'of', 'at', 'by', 'for', + 'with', 'about', 'against', 'between', 'into', 'through', 'during', 'before', 'after', 'above', 'below', 'to', + 'from', 'up', 'down', 'in', 'out', 'on', 'off', 'over', 'under', 'again', 'further', 'then', 'once', 'here', + 'there', 'when', 'where', 'why', 'how', 'all', 'any', 'both', 'each', 'few', 'more', 'most', 'other', 'some', + 'such', 'no', 'nor', 'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 's', 't', 'can', 'will', 'just', + 'don', 'should', 'now', 'd', 'll', 'm', 'o', 're', 've', 'y' + ]); + + for (const sentence of srtData.sentences) { + if (!sentence.struct || !sentence.text) continue; + + const sentenceSnippet = { index: sentence.index, text: sentence.text }; + + for (const block of sentence.struct.blocks) { + for (const part of block.blockParts) { + if (part.isWord) { + const word = part.content.toLowerCase(); + if (word && !stopWords.has(word)) { + if (!wordMap[word]) { + wordMap[word] = []; + } + // Avoid adding duplicate sentence for the same word + if (!wordMap[word].some(s => s.index === sentence.index)) { + wordMap[word].push(sentenceSnippet); + } + } + } + } + } + } + return wordMap; + } + registerRoutes(): void { registerRoute('subtitle/srt/parse-to-sentences', (p) => this.parseSrt(p)); + registerRoute('subtitle/extract-words', (p) => this.extractWords(p)); } } diff --git a/src/backend/services/CacheService.ts b/src/backend/services/CacheService.ts index f67a075c..17e080d2 100644 --- a/src/backend/services/CacheService.ts +++ b/src/backend/services/CacheService.ts @@ -1,7 +1,9 @@ import { SrtSentence } from '@/common/types/SentenceC'; +import { YdRes } from '@/common/types/YdRes'; -export type CacheType ={ +export type CacheType = { 'cache:srt': SrtSentence; + 'cache:translation': YdRes; } export default interface CacheService { diff --git a/src/common/types/store_schema.ts b/src/common/types/store_schema.ts index 2fc3c09f..a6916e0d 100644 --- a/src/common/types/store_schema.ts +++ b/src/common/types/store_schema.ts @@ -20,6 +20,7 @@ export const SettingKeyObj = { 'shortcut.toggleCopyMode': 'shift+y', 'shortcut.addClip': 'shift+l', 'shortcut.openControlPanel': 'shift+p', + 'shortcut.toggleWordList': 'shift+k', 'userSelect.playbackRateStack':'', 'apiKeys.youdao.secretId': '', 'apiKeys.youdao.secretKey': '', diff --git a/src/fronted/components/PlayerSrtLayout.tsx b/src/fronted/components/PlayerSrtLayout.tsx index 13a71396..c42db8d2 100644 --- a/src/fronted/components/PlayerSrtLayout.tsx +++ b/src/fronted/components/PlayerSrtLayout.tsx @@ -10,6 +10,7 @@ import useFile from '@/fronted/hooks/useFile'; import useLayout from '@/fronted/hooks/useLayout'; import { useLocalStorage } from '@uidotdev/usehooks'; import StrUtil from '@/common/utils/str-util'; +import WordList from '@/fronted/components/word-list/WordList'; const PlayerSrtLayout = () => { const hasSubTitle = useFile((s) => StrUtil.isNotBlank(s.subtitlePath)); @@ -20,6 +21,9 @@ const PlayerSrtLayout = () => { const [sizeOb, setSizeOb] = useLocalStorage('split-size-ob', 25); const [sizeIa, setSizeIa] = useLocalStorage('split-size-ia', 80); const [sizeIb, setSizeIb] = useLocalStorage('split-size-ib', 20); + const showWordList = useLayout((s) => s.showWordList); + const [sizeRight, setSizeRight] = useLocalStorage('split-size-right', [50, 50]); // Moved to local usage or removed if not needed + return (
{ )} - {!fullScreen && ( + {(!fullScreen && hasSubTitle) && ( <> { setSizeOb(e); }} > - + {showWordList ? ( + + + + + + + + + + ) : ( + + )} )} diff --git a/src/fronted/components/short-cut/GlobalShortCut.tsx b/src/fronted/components/short-cut/GlobalShortCut.tsx index a04e79b7..a5475c5d 100644 --- a/src/fronted/components/short-cut/GlobalShortCut.tsx +++ b/src/fronted/components/short-cut/GlobalShortCut.tsx @@ -1,5 +1,6 @@ import useSetting from '../../hooks/useSetting'; import {useHotkeys} from "react-hotkeys-hook"; +import useLayout from "@/fronted/hooks/useLayout"; const process = (values: string) => values .split(',') @@ -9,8 +10,15 @@ export default function GlobalShortCut() { const setting = useSetting((s) => s.setting); const setSetting = useSetting((s) => s.setSetting); + const toggleWordList = useLayout((s) => s.toggleWordList); + useHotkeys(process(setting('shortcut.nextTheme')), () => { setSetting('appearance.theme', setting('appearance.theme') === 'dark' ? 'light' : 'dark'); }); + + useHotkeys(process(setting('shortcut.toggleWordList')), () => { + toggleWordList(); + }); + return <>; } diff --git a/src/fronted/components/word-list/WordList.tsx b/src/fronted/components/word-list/WordList.tsx new file mode 100644 index 00000000..2c36da95 --- /dev/null +++ b/src/fronted/components/word-list/WordList.tsx @@ -0,0 +1,67 @@ +import React, { useState } from 'react'; +import useSWR from 'swr'; +import useFile from '@/fronted/hooks/useFile'; +import { ScrollArea } from '@/fronted/components/ui/scroll-area'; +import { Loader2 } from 'lucide-react'; +import WordListWord from './WordListWord'; +import StrUtil from '@/common/utils/str-util'; + +const api = window.electron; + +type WordMap = Record; + +const WordList: React.FC = () => { + const subtitlePath = useFile((s) => s.subtitlePath); + const [openWord, setOpenWord] = useState(null); + + const { data: wordMap, isLoading } = useSWR( + subtitlePath ? ['subtitle/extract-words', subtitlePath] : null, + ([_key, path]) => api.call('subtitle/extract-words', path) + ); + + const words = wordMap ? Object.keys(wordMap).sort() : []; + + const handleToggle = (word: string) => { + setOpenWord(prev => prev === word ? null : word); + }; + + return ( +
+
+

Word List

+
+ +
+ {isLoading && ( +
+ +
+ )} + {!isLoading && (words.length === 0) && ( +
+ {StrUtil.isNotBlank(subtitlePath) + ? 'No unique words found.' + : 'No subtitle file loaded.'} +
+ )} + {words.length > 0 && ( +
+ {words.map((word) => ( + handleToggle(word)} + /> + ))} +
+ )} +
+
+
+ ); +}; + +export default WordList; + diff --git a/src/fronted/components/word-list/WordListWord.tsx b/src/fronted/components/word-list/WordListWord.tsx new file mode 100644 index 00000000..69a7d8fa --- /dev/null +++ b/src/fronted/components/word-list/WordListWord.tsx @@ -0,0 +1,115 @@ +import React, { useState } from 'react'; +import useSWR from 'swr'; +import { YdRes } from '@/common/types/YdRes'; +import { Loader2, SendToBack } from 'lucide-react'; +import { useClick, useDismiss, useFloating, useInteractions } from '@floating-ui/react'; +import usePlayerController from '@/fronted/hooks/usePlayerController'; +import { ScrollArea } from '@/fronted/components/ui/scroll-area'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/fronted/components/ui/tabs'; +import { cn } from '@/fronted/lib/utils'; + +const api = window.electron; + +interface WordListWordProps { + word: string; + sentences: { index: number; text: string }[]; + isOpen: boolean; + onToggle: () => void; +} + +const WordListWord: React.FC = ({ word, sentences, isOpen, onToggle }) => { + + const { data: ydResp, isLoading } = useSWR( + isOpen ? ['ai-trans/word', word] : null, + ([_key, wordToTranslate]) => api.call('ai-trans/word', wordToTranslate) + ); + + const { refs, context } = useFloating({ + open: isOpen, + onOpenChange: onToggle, + placement: 'bottom-start', + }); + + const dismiss = useDismiss(context); + const click = useClick(context); + const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]); + + const handleJumpToSentence = (sentenceIndex: number) => { + const sentenceToJump = usePlayerController.getState().subtitle.find(s => s.index === sentenceIndex); + if (sentenceToJump) { + usePlayerController.getState().jump(sentenceToJump); + } + onToggle(); // Close popover after jumping + }; + + return ( + <> + {/* 1. The trigger element is always visible */} + + {word} + + + {/* 2. The floating popover content */} + {isOpen && ( +
+ {/* 3. The content of the popover is the Tabs component */} + + + Word Card + Sentences + + + + {(isLoading || !ydResp) && ( +
+ +
+ )} + {ydResp?.webdict?.url && ( +
+