├── .env.development ├── .npmrc ├── .env.production_chrome ├── .cursorrules ├── public ├── bibigpt.png ├── bibijun.png ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon-48x48.png ├── favicon-64x64.png ├── favicon-128x128.png ├── youtube-caption.png └── _locales │ └── zh_CN │ └── messages.json ├── postcss.config.cjs ├── src ├── message │ ├── const.ts │ ├── util.ts │ ├── messagingUtil.ts │ ├── index.ts │ ├── typings.ts │ ├── layer2 │ │ ├── useMessaging.ts │ │ ├── useMessagingService.ts │ │ ├── InjectMessaging.ts │ │ └── ExtensionMessaging.ts │ └── layer1 │ │ └── Layer1Protocol.ts ├── vite-env.d.ts ├── utils │ ├── chromeUtils.ts │ ├── env_util.ts │ ├── storage.ts │ ├── storage_web.ts │ ├── storage_chrome_client.ts │ ├── req_util.ts │ ├── search.ts │ ├── Waiter.ts │ ├── pinyinUtil.ts │ ├── req.ts │ ├── bizUtil.ts │ └── util.ts ├── hooks │ ├── redux.ts │ ├── useEventChecked.ts │ ├── useSubtitle.ts │ ├── useMessageService.ts │ ├── useTranslateService.ts │ ├── useLocalStorage.ts │ ├── useSearchService.ts │ ├── useKeyService.ts │ ├── useSubtitleService.ts │ └── useTranslate.ts ├── index.less ├── redux │ ├── currentTimeReducer.ts │ └── envReducer.ts ├── store.ts ├── Main.tsx ├── components │ ├── Popover.module.less │ ├── ApiKeyReminder.tsx │ ├── Popover.tsx │ ├── Markdown.tsx │ ├── DebateChat.tsx │ ├── CompactSegmentItem.tsx │ ├── NormalSegmentItem.tsx │ ├── RateExtension.tsx │ ├── SegmentItem.tsx │ ├── Ask.tsx │ ├── Header.tsx │ ├── MoreBtn.tsx │ └── Body.tsx ├── Router.tsx ├── chrome │ ├── openaiService.ts │ ├── taskService.ts │ └── background.ts ├── pages │ └── MainPage.tsx ├── App.tsx ├── message-typings.d.ts ├── typings.d.ts ├── consts │ └── const.ts └── inject │ └── inject.ts ├── .eslintignore ├── tsconfig.node.json ├── .editorconfig ├── .gitignore ├── fix.cjs ├── index.html ├── sidepanel.html ├── options.html ├── tsconfig.json ├── vite.config.ts ├── tailwind.config.cjs ├── LICENSE ├── push.sh ├── .eslintrc.cjs ├── README.md ├── manifest.config.ts └── package.json /.env.development: -------------------------------------------------------------------------------- 1 | VITE_ENV=web-dev 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmmirror.com/ 2 | -------------------------------------------------------------------------------- /.env.production_chrome: -------------------------------------------------------------------------------- 1 | NODE_ENV=production 2 | 3 | VITE_ENV=chrome -------------------------------------------------------------------------------- /.cursorrules: -------------------------------------------------------------------------------- 1 | This chrome extension project use typescript, react, vite, tailwindcss, daisyui. -------------------------------------------------------------------------------- /public/bibigpt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IndieKKY/bilibili-subtitle/HEAD/public/bibigpt.png -------------------------------------------------------------------------------- /public/bibijun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IndieKKY/bilibili-subtitle/HEAD/public/bibijun.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IndieKKY/bilibili-subtitle/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IndieKKY/bilibili-subtitle/HEAD/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IndieKKY/bilibili-subtitle/HEAD/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IndieKKY/bilibili-subtitle/HEAD/public/favicon-48x48.png -------------------------------------------------------------------------------- /public/favicon-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IndieKKY/bilibili-subtitle/HEAD/public/favicon-64x64.png -------------------------------------------------------------------------------- /public/favicon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IndieKKY/bilibili-subtitle/HEAD/public/favicon-128x128.png -------------------------------------------------------------------------------- /public/youtube-caption.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/IndieKKY/bilibili-subtitle/HEAD/public/youtube-caption.png -------------------------------------------------------------------------------- /postcss.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/message/const.ts: -------------------------------------------------------------------------------- 1 | export const TAG_TARGET_INJECT = 'target:inject' 2 | export const TAG_TARGET_APP = 'target:app' 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | public/ 2 | dist/ 3 | node_modules/ 4 | postcss.config.cjs 5 | vite.config.ts 6 | vite-env.d.ts 7 | manifest.config.ts -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMetaEnv { 4 | } 5 | 6 | interface ImportMeta { 7 | readonly env: ImportMetaEnv 8 | } 9 | -------------------------------------------------------------------------------- /public/_locales/zh_CN/messages.json: -------------------------------------------------------------------------------- 1 | { 2 | "appName": { 3 | "message": "哔哔君 - bilibili哔哩哔哩字幕列表" 4 | }, 5 | "appDescription": { 6 | "message": "显示B站视频的字幕列表,可点击跳转与下载字幕,并支持翻译和总结字幕!" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/chromeUtils.ts: -------------------------------------------------------------------------------- 1 | export const openOptionsPage = () => { 2 | if (chrome.runtime.openOptionsPage) { 3 | chrome.runtime.openOptionsPage() 4 | } else { 5 | window.open(chrome.runtime.getURL('options.html')) 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "module": "ESNext", 5 | "moduleResolution": "Node", 6 | "allowSyntheticDefaultImports": true, 7 | "resolveJsonModule": true 8 | }, 9 | "include": ["vite.config.ts", "manifest.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/utils/env_util.ts: -------------------------------------------------------------------------------- 1 | export const openUrl = (url?: string, target?: string, features?: string) => { 2 | if (url) { 3 | window.open(url, target, features) 4 | } 5 | } 6 | 7 | export const isDarkMode = () => { 8 | return window.matchMedia?.('(prefers-color-scheme: dark)').matches 9 | } 10 | -------------------------------------------------------------------------------- /src/message/util.ts: -------------------------------------------------------------------------------- 1 | import { L2ResMsg } from './typings' 2 | 3 | /** 4 | * 处理响应信息 5 | * @returns data 6 | */ 7 | export const handleRes = (res: L2ResMsg): any => { 8 | if (res.code === 200) { 9 | return res.data 10 | } else { 11 | throw new Error(`${res.code}: ${res.msg || 'Unknown error'}`) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | indent_size = 3 15 | 16 | [Makefile] 17 | indent_style = tab 18 | -------------------------------------------------------------------------------- /src/hooks/redux.ts: -------------------------------------------------------------------------------- 1 | import {TypedUseSelectorHook, useDispatch, useSelector} from 'react-redux' 2 | import type {AppDispatch, RootState} from '../store' 3 | 4 | // Use throughout your app instead of plain `useDispatch` and `useSelector` 5 | export const useAppDispatch: () => AppDispatch = useDispatch 6 | export const useAppSelector: TypedUseSelectorHook = useSelector 7 | -------------------------------------------------------------------------------- /src/hooks/useEventChecked.ts: -------------------------------------------------------------------------------- 1 | import {useCallback, useState} from 'react' 2 | 3 | const useEventChecked = (initValue?: boolean) => { 4 | const [value, setValue] = useState(initValue) 5 | 6 | const onChange = useCallback((e: any) => { 7 | setValue(e.target.checked) 8 | }, []) 9 | 10 | return {value, setValue, onChange} 11 | } 12 | 13 | export default useEventChecked 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | .pnpm-store 15 | 16 | # Editor directories and files 17 | .vscode/* 18 | !.vscode/extensions.json 19 | .idea 20 | .DS_Store 21 | *.suo 22 | *.ntvs* 23 | *.njsproj 24 | *.sln 25 | *.sw? 26 | 27 | stats.html 28 | dist.zip 29 | 360/ 30 | -------------------------------------------------------------------------------- /fix.cjs: -------------------------------------------------------------------------------- 1 | console.log('fix.js loaded'); 2 | 3 | const fs = require('fs') 4 | 5 | //copy index.html to sidepanel.html 6 | fs.copyFileSync('./dist/index.html', './dist/sidepanel.html') 7 | 8 | //set all use_dynamic_url to false 9 | const manifest = require('./dist/manifest.json') 10 | manifest.web_accessible_resources.forEach(resource => { 11 | resource.use_dynamic_url = false 12 | }) 13 | fs.writeFileSync('./dist/manifest.json', JSON.stringify(manifest, null, 2)) 14 | 15 | console.log('fix.js done'); 16 | -------------------------------------------------------------------------------- /src/index.less: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | font-size: 16px; 7 | } 8 | body { 9 | font-size: 100%; 10 | } 11 | 12 | #bilibili-subtitle { 13 | //font-family: PingFang SC, HarmonyOS_Regular, Helvetica Neue, Microsoft YaHei, sans-serif; 14 | font-size: 16px; 15 | text-align: left; 16 | } 17 | 18 | .desc { 19 | @apply text-base-content/80; 20 | } 21 | 22 | .desc-lighter { 23 | @apply text-base-content/60; 24 | } 25 | 26 | .flex-center { 27 | @apply flex items-center; 28 | } 29 | -------------------------------------------------------------------------------- /src/message/messagingUtil.ts: -------------------------------------------------------------------------------- 1 | import { TAG_TARGET_APP } from './const' 2 | import Layer1Protocol from './layer1/Layer1Protocol' 3 | 4 | export const sendHandshakeFromApp = (pmh: Layer1Protocol) => { 5 | // 初始化 6 | // get tabId from url params 7 | const tabIdStr = window.location.search.split('tabId=')[1] 8 | const tabId = tabIdStr ? parseInt(tabIdStr) : undefined 9 | pmh.sendMessage({ 10 | from: 'app', 11 | target: 'extension', 12 | method: '_HANDSHAKE', 13 | params: { 14 | tabId, 15 | tag: TAG_TARGET_APP, 16 | }, 17 | }) 18 | } 19 | -------------------------------------------------------------------------------- /src/redux/currentTimeReducer.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit' 2 | 3 | interface CurrentTimeState { 4 | currentTime?: number 5 | } 6 | 7 | const initialState: CurrentTimeState = { 8 | currentTime: undefined 9 | } 10 | 11 | export const slice = createSlice({ 12 | name: 'currentTime', 13 | initialState, 14 | reducers: { 15 | setCurrentTime: (state, action: PayloadAction) => { 16 | state.currentTime = action.payload 17 | } 18 | } 19 | }) 20 | 21 | export const { setCurrentTime } = slice.actions 22 | 23 | export default slice.reducer 24 | -------------------------------------------------------------------------------- /src/store.ts: -------------------------------------------------------------------------------- 1 | import {configureStore} from '@reduxjs/toolkit' 2 | import envReducer from './redux/envReducer' 3 | import currentTimeReducer from './redux/currentTimeReducer' 4 | 5 | const store = configureStore({ 6 | reducer: { 7 | env: envReducer, 8 | currentTime: currentTimeReducer, 9 | }, 10 | }) 11 | 12 | export default store 13 | // Infer the `RootState` and `AppDispatch` types from the store itself 14 | export type RootState = ReturnType 15 | // Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} 16 | export type AppDispatch = typeof store.dispatch 17 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 哔哔君 - 哔哩哔哩字幕列表 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /sidepanel.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 哔哔君 - 哔哩哔哩字幕列表 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/message/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Layer1Protocol } from './layer1/Layer1Protocol' 2 | export { default as ExtensionMessaging } from './layer2/ExtensionMessaging' 3 | export { default as InjectMessaging } from './layer2/InjectMessaging' 4 | export { default as useMessaging } from './layer2/useMessaging' 5 | export { default as useMessagingService } from './layer2/useMessagingService' 6 | export * from './const' 7 | export type { 8 | Message, 9 | ExtensionMessage, 10 | InjectMessage, 11 | AppMessage, 12 | MethodContext, 13 | ExtensionHandshakeMessage, 14 | ExtensionRouteMessage, 15 | MessagingExtensionMessages 16 | } from './typings' 17 | -------------------------------------------------------------------------------- /src/Main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import './index.less' 4 | import store from './store' 5 | import {Provider} from 'react-redux' 6 | import Router from './Router' 7 | import { APP_DOM_ID } from './consts/const' 8 | 9 | const body = document.querySelector('body') 10 | const app = document.createElement('div') 11 | app.id = APP_DOM_ID 12 | if (body != null) { 13 | body.prepend(app) 14 | } 15 | 16 | ReactDOM.createRoot(document.getElementById(APP_DOM_ID) as HTMLElement).render( 17 | 18 | 19 | 20 | 21 | 22 | ) 23 | -------------------------------------------------------------------------------- /src/components/Popover.module.less: -------------------------------------------------------------------------------- 1 | .arrow, .arrow::before { 2 | position: absolute; 3 | width: 8px; 4 | height: 8px; 5 | background: inherit; 6 | } 7 | 8 | .arrow { 9 | visibility: hidden; 10 | } 11 | 12 | .arrow::before { 13 | visibility: visible; 14 | content: ''; 15 | transform: rotate(45deg); 16 | } 17 | 18 | .tooltip[data-popper-placement^='top'] > .arrow { 19 | bottom: -4px; 20 | } 21 | 22 | .tooltip[data-popper-placement^='bottom'] > .arrow { 23 | top: -4px; 24 | } 25 | 26 | .tooltip[data-popper-placement^='left'] > .arrow { 27 | right: -4px; 28 | } 29 | 30 | .tooltip[data-popper-placement^='right'] > .arrow { 31 | left: -4px; 32 | } 33 | -------------------------------------------------------------------------------- /options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 哔哔君 - 哔哩哔哩字幕列表选项 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/utils/storage.ts: -------------------------------------------------------------------------------- 1 | import {storageWeb} from './storage_web' 2 | import {storageChromeClient} from './storage_chrome_client' 3 | 4 | export interface IStorage { 5 | setStore: (key: string, data?: string) => Promise 6 | delStore: (key: string) => Promise 7 | getStore: (key: string) => Promise 8 | getStoreKeys: () => Promise 9 | } 10 | 11 | export type StorageType = 'web' | 'chrome_client' 12 | 13 | export const getStorage = (type: StorageType): IStorage => { 14 | switch (type) { 15 | case 'web': 16 | return storageWeb 17 | case 'chrome_client': 18 | return storageChromeClient 19 | default: 20 | throw new Error('unknown storage type') 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "useDefineForClassFields": true, 5 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 6 | "allowJs": false, 7 | "skipLibCheck": true, 8 | "esModuleInterop": false, 9 | "allowSyntheticDefaultImports": true, 10 | "strict": true, 11 | "forceConsistentCasingInFileNames": true, 12 | "module": "ESNext", 13 | "moduleResolution": "Node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | "types": [ 19 | "@types/chrome" 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["src"], 26 | "references": [{ "path": "./tsconfig.node.json" }] 27 | } 28 | -------------------------------------------------------------------------------- /src/components/ApiKeyReminder.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useAppSelector } from '../hooks/redux' 3 | import { openOptionsPage } from '../utils/chromeUtils' 4 | 5 | const ApiKeyReminder: React.FC = () => { 6 | const apiKey = useAppSelector(state => state.env.envData.apiKey) 7 | 8 | if (apiKey) { 9 | return null 10 | } 11 | 12 | return ( 13 |
14 | 请先设置API密钥以使用总结及翻译功能 15 | 21 |
22 | ) 23 | } 24 | 25 | export default ApiKeyReminder 26 | -------------------------------------------------------------------------------- /src/utils/storage_web.ts: -------------------------------------------------------------------------------- 1 | import {IStorage} from './storage' 2 | 3 | /** 4 | * 存储: web 5 | */ 6 | export default class StorageWeb implements IStorage { 7 | async setStore(key: string, data?: string) { 8 | if (data) { 9 | localStorage.setItem(key, data) 10 | } else { 11 | localStorage.removeItem(key) 12 | } 13 | } 14 | 15 | async delStore(key: string) { 16 | localStorage.removeItem(key) 17 | } 18 | 19 | async getStore(key: string): Promise { 20 | return localStorage.getItem(key) 21 | } 22 | 23 | async getStoreKeys() { 24 | const result: string[] = [] 25 | for (let i = 0; i < window.localStorage.length; i++) { 26 | const key = window.localStorage.key(i) 27 | if (key) { 28 | result.push(key) 29 | } 30 | } 31 | return result 32 | } 33 | } 34 | 35 | export const storageWeb = new StorageWeb() 36 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig, PluginOption} from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | import {visualizer} from "rollup-plugin-visualizer"; 4 | import {crx} from '@crxjs/vite-plugin' 5 | import path from "path" 6 | // @ts-ignore 7 | import manifest from './manifest.config' 8 | 9 | // https://vitejs.dev/config/ 10 | export default () => { 11 | return defineConfig({ 12 | base: '/', 13 | build: { 14 | rollupOptions: { 15 | input: { 16 | index: 'index.html', 17 | }, 18 | }, 19 | }, 20 | resolve: { 21 | alias: { 22 | '@': path.resolve(__dirname, './src'), 23 | } 24 | }, 25 | plugins: [ 26 | react(), 27 | crx({ 28 | manifest, 29 | }), 30 | visualizer() as PluginOption, 31 | ], 32 | css: { 33 | modules: { 34 | localsConvention: "camelCase" 35 | } 36 | } 37 | }) 38 | } 39 | -------------------------------------------------------------------------------- /src/Router.tsx: -------------------------------------------------------------------------------- 1 | import App from './App' 2 | import {useEventEmitter} from 'ahooks' 3 | import React, { useEffect } from 'react' 4 | import { useAppDispatch } from './hooks/redux' 5 | import { setPath } from './redux/envReducer' 6 | 7 | export const EventBusContext = React.createContext(null) 8 | 9 | const map: { [key: string]: string } = { 10 | '/options.html': 'options', 11 | '/sidepanel.html': 'app', 12 | // '/close': 'close', 13 | } 14 | 15 | const Router = () => { 16 | const path = map[window.location.pathname] ?? 'app' 17 | const dispatch = useAppDispatch() 18 | 19 | if (path === 'close') { 20 | window.close() 21 | } 22 | 23 | // 事件总线 24 | const eventBus = useEventEmitter() 25 | 26 | useEffect(() => { 27 | dispatch(setPath(path as 'app' | 'options')) 28 | }, [dispatch, path]) 29 | 30 | return 31 | {(path === 'app' || path === 'options') && } 32 | 33 | } 34 | 35 | export default Router 36 | -------------------------------------------------------------------------------- /tailwind.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | darkMode: 'class', 4 | content: ["./src/**/*.{js,ts,jsx,tsx}"], 5 | theme: { 6 | extend: { 7 | fontSize: { 8 | xs: '13px', 9 | }, 10 | }, 11 | }, 12 | plugins: [ 13 | require('tailwind-scrollbar-hide'), 14 | require('@tailwindcss/line-clamp'), 15 | require('@tailwindcss/typography'), 16 | require('daisyui'), 17 | ], 18 | 19 | daisyui: { 20 | styled: true, 21 | themes: [{ 22 | light: { 23 | ...require("daisyui/src/colors/themes")["[data-theme=light]"], 24 | "--rounded-btn": "0.15rem", 25 | "primary": "rgb(0, 174, 236)", 26 | }, 27 | }, { 28 | dark: { 29 | ...require("daisyui/src/colors/themes")["[data-theme=dark]"], 30 | "--rounded-btn": "0.15rem", 31 | "primary": "rgb(0, 174, 236)", 32 | } 33 | }], 34 | base: true, 35 | utils: true, 36 | logs: true, 37 | rtl: false, 38 | prefix: "", 39 | darkTheme: "dark", 40 | }, 41 | } 42 | -------------------------------------------------------------------------------- /src/utils/storage_chrome_client.ts: -------------------------------------------------------------------------------- 1 | import {IStorage} from './storage' 2 | 3 | /** 4 | * 存储: chrome extension client 5 | * 需要配合background.js使用 6 | */ 7 | export default class StorageChromeClient implements IStorage { 8 | async setStore(key: string, data?: string) { 9 | if (data) { 10 | await chrome.runtime.sendMessage({ 11 | type: 'syncSet', 12 | items: { 13 | [key]: data, 14 | } 15 | }) 16 | } else { 17 | await chrome.runtime.sendMessage({ 18 | type: 'syncRemove', 19 | keys: key 20 | }) 21 | } 22 | } 23 | 24 | async delStore(key: string) { 25 | await chrome.runtime.sendMessage({ 26 | type: 'syncRemove', 27 | keys: key 28 | }) 29 | } 30 | 31 | async getStore(key: string): Promise { 32 | const resultMap = await chrome.runtime.sendMessage({ 33 | type: 'syncGet', 34 | keys: [key] 35 | }) 36 | return resultMap?.[key] 37 | } 38 | 39 | async getStoreKeys() { 40 | return undefined 41 | } 42 | } 43 | 44 | export const storageChromeClient = new StorageChromeClient() 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2023 IndieKKY 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/utils/req_util.ts: -------------------------------------------------------------------------------- 1 | export interface Result { 2 | success: boolean 3 | code: string 4 | message?: string 5 | data?: any 6 | } 7 | 8 | /** 9 | * handle the business logic of response 10 | * 11 | * if the response is not ok, handle it and throw an error, 12 | * otherwise, do nothing 13 | */ 14 | export const handleResp = async (resp: Response, data: Result, errorHandler?: (err: Error) => void) => { 15 | errorHandler = errorHandler ?? (() => {}) 16 | if (resp.ok) { 17 | // 处理返回数据 18 | if (!data.success) { 19 | const error = new Error(data.message) 20 | // @ts-expect-error 21 | error._respData = data 22 | errorHandler(error) 23 | throw error 24 | } 25 | } else { 26 | if (resp.status === 401) { 27 | const error1 = new Error('未登录') 28 | errorHandler(error1) 29 | throw error1 30 | } else if (resp.status === 403) { 31 | const error1 = new Error('没有权限') 32 | errorHandler(error1) 33 | throw error1 34 | } else { 35 | const error1 = new Error(`异常(状态码: ${resp.status})`) 36 | errorHandler(error1) 37 | throw error1 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /push.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Read the current version from package.json 4 | current_version=$(cat package.json | grep '"version"' | cut -d'"' -f4) 5 | echo "Current version: $current_version" 6 | 7 | # Prompt the user to select which part of the version to upgrade 8 | echo "Which part of the version do you want to upgrade?" 9 | select part in "MAJOR" "MINOR" "PATCH"; do 10 | case $part in 11 | MAJOR) new_version=$(echo $current_version | awk 'BEGIN{FS=OFS="."} {$1+=1; $2=0; $3=0; print}'); break;; 12 | MINOR) new_version=$(echo $current_version | awk 'BEGIN{FS=OFS="."} {$2+=1; $3=0; print}'); break;; 13 | PATCH) new_version=$(echo $current_version | awk 'BEGIN{FS=OFS="."} {$3+=1; print}'); break;; 14 | *) echo "Invalid option";; 15 | esac 16 | done 17 | 18 | # Update the version in package.json 19 | sed -i '' "s/\"version\": \"$current_version\"/\"version\": \"$new_version\"/g" package.json 20 | 21 | echo "Version updated to: $new_version" 22 | 23 | # build 24 | pnpm run build 25 | # zip dist 26 | rm -f dist.zip 27 | cd dist 28 | zip -r ../dist.zip ./* 29 | cd ../ 30 | 31 | # git 32 | git add . 33 | git commit -m "chore: release $new_version" 34 | git push 35 | 36 | # tag 37 | git tag $new_version 38 | git push origin $new_version 39 | 40 | -------------------------------------------------------------------------------- /src/message/typings.ts: -------------------------------------------------------------------------------- 1 | // 请求信息 2 | export interface L2ReqMsg { 3 | from: 'extension' | 'inject' | 'app' 4 | target: 'extension' | 'inject' | 'app' 5 | method: string 6 | params?: any 7 | } 8 | 9 | // 响应信息 10 | export interface L2ResMsg { 11 | code: number 12 | msg?: string 13 | data?: L2Res 14 | } 15 | 16 | export interface Message { 17 | method: string 18 | params: T 19 | return: R 20 | } 21 | 22 | export interface ExtensionMessage extends Message { 23 | } 24 | 25 | export interface InjectMessage extends Message { 26 | } 27 | 28 | export interface AppMessage extends Message { 29 | } 30 | 31 | export interface MethodContext { 32 | from: 'extension' | 'inject' | 'app' 33 | event: any 34 | tabId?: number 35 | // sender?: chrome.runtime.MessageSender | null 36 | } 37 | 38 | export interface ExtensionHandshakeMessage extends ExtensionMessage<{ tabId?: number, tag: string }> { 39 | method: '_HANDSHAKE' 40 | } 41 | 42 | export interface ExtensionRouteMessage extends ExtensionMessage<{ usePort: boolean, tag: string, method: string, params: any }> { 43 | method: '_ROUTE' 44 | } 45 | 46 | export type MessagingExtensionMessages = ExtensionHandshakeMessage | ExtensionRouteMessage 47 | -------------------------------------------------------------------------------- /src/chrome/openaiService.ts: -------------------------------------------------------------------------------- 1 | import {DEFAULT_SERVER_URL_OPENAI} from '../consts/const' 2 | 3 | const getServerUrl = (serverUrl?: string) => { 4 | if (!serverUrl) { 5 | return DEFAULT_SERVER_URL_OPENAI 6 | } 7 | if (serverUrl.endsWith('/')) { 8 | serverUrl = serverUrl.slice(0, -1) 9 | } 10 | // 如果serverUrl以https://generativelanguage.googleapis.com开头,则直接返回 11 | if (serverUrl.toLowerCase().startsWith('https://generativelanguage.googleapis.com')) { 12 | return serverUrl 13 | } 14 | // 如果serverUrl不以/vxxx结尾,则添加/v1 15 | if (!/\/v\d+$/.test(serverUrl.toLowerCase())) { 16 | serverUrl += '/v1' 17 | } 18 | return serverUrl 19 | } 20 | 21 | export const handleChatCompleteTask = async (task: Task) => { 22 | const data = task.def.data 23 | const serverUrl = getServerUrl(task.def.serverUrl) 24 | const resp = await fetch(`${serverUrl}/chat/completions`, { 25 | method: 'POST', 26 | headers: { 27 | 'Content-Type': 'application/json', 28 | Authorization: 'Bearer ' + task.def.extra.apiKey, 29 | }, 30 | body: JSON.stringify(data), 31 | }) 32 | task.resp = await resp.json() 33 | if (task.resp.usage) { 34 | return (task.resp.usage.total_tokens??0) > 0 35 | } else { 36 | throw new Error(`${task.resp.error.code as string??''} ${task.resp.error.message as string ??''}`) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/hooks/useSubtitle.ts: -------------------------------------------------------------------------------- 1 | import {useAppDispatch, useAppSelector} from './redux' 2 | import React, {useCallback} from 'react' 3 | import {setNeedScroll, setReviewAction, setTempData} from '../redux/envReducer' 4 | import { useMessage } from './useMessageService' 5 | const useSubtitle = () => { 6 | const dispatch = useAppDispatch() 7 | const envData = useAppSelector(state => state.env.envData) 8 | const reviewed = useAppSelector(state => state.env.tempData.reviewed) 9 | const reviewAction = useAppSelector(state => state.env.reviewAction) 10 | const reviewActions = useAppSelector(state => state.env.tempData.reviewActions) 11 | const {sendInject} = useMessage(!!envData.sidePanel) 12 | 13 | const move = useCallback((time: number, togglePause: boolean) => { 14 | sendInject(null, 'MOVE', {time, togglePause}) 15 | 16 | // review action 17 | if (reviewed === undefined && !reviewAction) { 18 | dispatch(setReviewAction(true)) 19 | dispatch(setTempData({ 20 | reviewActions: (reviewActions ?? 0) + 1 21 | })) 22 | } 23 | }, [dispatch, reviewAction, reviewActions, reviewed, sendInject]) 24 | 25 | const scrollIntoView = useCallback((ref: React.RefObject) => { 26 | ref.current?.scrollIntoView({behavior: 'smooth', block: 'center'}) 27 | dispatch(setNeedScroll(false)) 28 | }, [dispatch]) 29 | 30 | return {move, scrollIntoView} 31 | } 32 | 33 | export default useSubtitle 34 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "es2021": true 5 | }, 6 | "extends": [ 7 | "plugin:react/recommended", 8 | "plugin:react-hooks/recommended", 9 | "standard-with-typescript", 10 | ], 11 | "overrides": [], 12 | "parserOptions": { 13 | "project": "tsconfig.json", 14 | "ecmaVersion": "latest", 15 | "sourceType": "module" 16 | }, 17 | "plugins": [ 18 | "react" 19 | ], 20 | "rules": { 21 | "react-hooks/exhaustive-deps": "error", 22 | "@typescript-eslint/explicit-function-return-type": "off", 23 | "react/react-in-jsx-scope": "off", 24 | "@typescript-eslint/restrict-plus-operands": "off", 25 | "@typescript-eslint/no-empty-interface": "warn", 26 | "@typescript-eslint/no-unused-vars": "warn", 27 | "@typescript-eslint/strict-boolean-expressions": "warn", 28 | "@typescript-eslint/prefer-nullish-coalescing": "off", 29 | "@typescript-eslint/no-non-null-assertion": "warn", 30 | "@typescript-eslint/object-curly-spacing": "off", 31 | "@typescript-eslint/no-misused-promises": "off", 32 | "@typescript-eslint/space-infix-ops": "off", 33 | "@typescript-eslint/no-floating-promises": "off", 34 | "operator-linebreak": "off", 35 | "@typescript-eslint/space-before-function-paren": "off", 36 | "@typescript-eslint/comma-dangle": "off" 37 | }, 38 | "settings": { 39 | "react": { 40 | "version": "detect" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/components/Popover.tsx: -------------------------------------------------------------------------------- 1 | import {PropsWithChildren, useState} from 'react' 2 | import {Modifier, usePopper} from 'react-popper' 3 | import popoverStyles from './Popover.module.less' 4 | import * as PopperJS from '@popperjs/core' 5 | import classNames from 'classnames' 6 | 7 | interface Props extends PropsWithChildren { 8 | /** 9 | * 用于定位弹出框的元素 10 | */ 11 | refElement: Element | PopperJS.VirtualElement | null 12 | className?: string | undefined 13 | arrowClassName?: string | undefined 14 | options?: Omit, 'modifiers'> & { 15 | createPopper?: typeof PopperJS.createPopper 16 | modifiers?: ReadonlyArray> 17 | } 18 | } 19 | 20 | const Popover = (props: Props) => { 21 | const {children, className, arrowClassName, refElement, options} = props 22 | 23 | const [popperElement, setPopperElement] = useState(null) 24 | const [arrowElement, setArrowElement] = useState(null) 25 | const { styles, attributes } = usePopper(refElement, popperElement, { 26 | placement: 'top', 27 | modifiers: [ 28 | {name: 'arrow', options: {element: arrowElement}}, 29 | {name: 'offset', options: {offset: [0, 8]}}, 30 | ], 31 | ...options??{}, 32 | }) 33 | 34 | return
35 |
36 | {children} 37 |
38 | } 39 | 40 | export default Popover 41 | -------------------------------------------------------------------------------- /src/components/Markdown.tsx: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import ReactMarkdown from 'react-markdown' 3 | import toast from 'react-hot-toast' 4 | 5 | function CopyBtn(props: { 6 | content: string 7 | }) { 8 | const {content} = props 9 | return
10 | 16 |
17 | } 18 | 19 | function Markdown(props: { 20 | content: string 21 | codeBlockClass?: string 22 | }) { 23 | const {content, codeBlockClass} = props 24 | 25 | return 33 | {children} 34 | 35 | } else { 36 | return 37 | {children} 38 | {className?.includes('language-copy') && } 39 | 40 | } 41 | } 42 | }} 43 | >{content} 44 | } 45 | 46 | export default Markdown 47 | -------------------------------------------------------------------------------- /src/hooks/useMessageService.ts: -------------------------------------------------------------------------------- 1 | import { setAuthor, setChapters, setCtime, setCurFetched, setCurInfo, setData, setInfos, setTitle, setUrl } from '@/redux/envReducer' 2 | import { useAppDispatch, useAppSelector } from './redux' 3 | import { AllAPPMessages, AllExtensionMessages, AllInjectMessages } from '@/message-typings' 4 | import { useMessaging, useMessagingService } from '../message' 5 | import { useMemoizedFn } from 'ahooks' 6 | 7 | const useMessageService = () => { 8 | const dispatch = useAppDispatch() 9 | const envData = useAppSelector((state) => state.env.envData) 10 | 11 | // methods 12 | const methodsFunc: () => { 13 | [K in AllAPPMessages['method']]: (params: Extract['params'], context: MethodContext) => Promise 14 | } = useMemoizedFn(() => ({ 15 | SET_INFOS: async (params, context: MethodContext) => { 16 | dispatch(setInfos(params.infos)) 17 | dispatch(setCurInfo(undefined)) 18 | dispatch(setCurFetched(false)) 19 | dispatch(setData(undefined)) 20 | }, 21 | SET_VIDEO_INFO: async (params, context: MethodContext) => { 22 | dispatch(setChapters(params.chapters)) 23 | dispatch(setInfos(params.infos)) 24 | dispatch(setUrl(params.url)) 25 | dispatch(setTitle(params.title)) 26 | dispatch(setCtime(params.ctime)) 27 | dispatch(setAuthor(params.author)) 28 | console.debug('video title: ', params.title) 29 | }, 30 | })) 31 | 32 | useMessagingService(!!envData.sidePanel, methodsFunc) 33 | } 34 | 35 | export default useMessageService 36 | export const useMessage = useMessaging 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## ⚠️维护说明 2 | 🙏感谢大家的支持,此开源版本将不再更新,仅进行bug修复! 3 | 新的扩展进行升级后,改名为vCaptions,支持任意网站视频添加字幕列表,应用商店链接不变。 4 | 5 | ## 简介 6 | 7 | 哔哩哔哩字幕列表是一个浏览器扩展,旨在提供更高效和可控的视频信息获取方式。 8 | 9 | 该扩展会显示视频的字幕列表,让用户能够快速浏览字幕内容,并通过点击跳转到相应的视频位置。同时,用户还可以方便地下载字幕文件。 10 | 11 | 除此之外,该扩展还提供了视频字幕总结功能,帮助用户快速掌握视频的要点。 12 | 13 | 该扩展主要面向知识学习类的视频,帮助用户更好地理解和总结视频内容。 14 | 15 | ## 功能特点 16 | 17 | - 🎬 显示视频的字幕列表 18 | - 🔗 点击字幕跳转视频对应位置 19 | - 📥 多种格式复制与下载字幕 20 | - 📝 多种方式总结字幕 21 | - 🌍 翻译字幕 22 | - 🌑 深色主题 23 | 24 | ## 下载扩展 25 | 26 | - [chrome商店](https://chrome.google.com/webstore/detail/bciglihaegkdhoogebcdblfhppoilclp) 27 | - [edge商店](https://microsoftedge.microsoft.com/addons/detail/lignnlhlpiefmcjkdkmfjdckhlaiajan) 28 | 29 | ## 使用说明 30 | 31 | 安装扩展后,在哔哩哔哩网站观看视频时,视频右侧会显示字幕列表面板。 32 | 33 | ### 使用本地Ollama模型 34 | 如果你使用本地Ollama模型,需要配置环境变量:`OLLAMA_ORIGINS=chrome-extension://*,moz-extension://*,safari-web-extension://*`,否则访问会出现403错误。 35 | 36 | 然后在插件配置里,apiKey随便填一个,服务器地址填`http://localhost:11434`,模型选自定义,然后填入自定义模型名如`llama2`。 37 | 38 | 但是测试发现llama2 7b模型比较弱,无法返回需要的json格式,因此总结很可能会无法解析响应而报错(但提问功能不需要解析响应格式,因此没问题)。 39 | 40 | ## 开发指南 41 | node版本:18.15.0 42 | 包管理器:pnpm 43 | 44 | - 本地调试:`pnpm run dev`,然后浏览器扩展管理页面,开启开发者模式,再加载已解压的扩展程序,选择`dist`目录。 45 | - 打生产包:`pnpm run build`,然后浏览器扩展管理页面,开启开发者模式,再加载已解压的扩展程序,选择`dist`目录。 46 | 47 | 注:`./push.sh`是作者自用脚本,可以忽略。 48 | 49 | 提示:最新版浏览器安全方面有更新,开发调试可能有问题,会报csp错误! 50 | 暂时的解决办法是`pnpm run dev`运行起来后,手动将`dist/manifest.json`文件里的web_accessible_resources里的use_dynamic_url都修改为false,然后浏览器扩展管理页面点击重载一下,就能正常(是@crxjs/vite-plugin依赖的问题,这个依赖很长时间没更新了,这个bug也没修复,暂时没发现更好的解决办法)。 51 | 构建后正常(关键是fix.cjs里将use_dynamic_url设置为false的这个操作)。 52 | 53 | ## 许可证 54 | 55 | 该项目采用 **MIT 许可证**,详情请参阅许可证文件。 56 | -------------------------------------------------------------------------------- /src/chrome/taskService.ts: -------------------------------------------------------------------------------- 1 | import {TASK_EXPIRE_TIME} from '../consts/const' 2 | import {handleChatCompleteTask} from './openaiService' 3 | 4 | export const tasksMap = new Map() 5 | 6 | export const handleTask = async (task: Task) => { 7 | console.debug(`处理任务: ${task.id} (type: ${task.def.type})`) 8 | try { 9 | task.status = 'running' 10 | switch (task.def.type) { 11 | case 'chatComplete': 12 | await handleChatCompleteTask(task) 13 | break 14 | default: 15 | // eslint-disable-next-line @typescript-eslint/restrict-template-expressions 16 | throw new Error(`任务类型不支持: ${task.def.type}`) 17 | } 18 | 19 | console.debug(`处理任务成功: ${task.id} (type: ${task.def.type})`) 20 | } catch (e: any) { 21 | task.error = e.message 22 | console.debug(`处理任务失败: ${task.id} (type: ${task.def.type})`, e.message) 23 | } 24 | task.status = 'done' 25 | task.endTime = Date.now() 26 | } 27 | 28 | export const initTaskService = () => { 29 | // 处理任务: tasksMap 30 | setInterval(() => { 31 | for (const [_, task] of tasksMap) { 32 | if (task.status === 'pending') { 33 | handleTask(task).catch(console.error) 34 | break 35 | } else if (task.status === 'running') { 36 | break 37 | } 38 | } 39 | }, 1000) 40 | // 检测清理tasksMap 41 | setInterval(() => { 42 | const now = Date.now() 43 | 44 | for (const [taskId, task] of tasksMap) { 45 | if (task.startTime < now - TASK_EXPIRE_TIME) { 46 | tasksMap.delete(taskId) 47 | console.debug(`清理任务: ${task.id} (type: ${task.def.type})`) 48 | } 49 | } 50 | }, 10000) 51 | } 52 | -------------------------------------------------------------------------------- /src/components/DebateChat.tsx: -------------------------------------------------------------------------------- 1 | import { useAppSelector } from '@/hooks/redux' 2 | import React from 'react' 3 | 4 | const DebateChat: React.FC = ({ messages }) => { 5 | const fontSize = useAppSelector(state => state.env.envData.fontSize) 6 | 7 | return ( 8 |
9 |
10 | {messages.map((message, index) => ( 11 |
17 |
24 |

25 | {message.side === 'pro' ? '正方' : '反方'} 26 |

27 |

{message.content}

28 |
35 |
36 |
37 | ))} 38 |
39 |
40 | ) 41 | } 42 | 43 | export default DebateChat 44 | -------------------------------------------------------------------------------- /src/hooks/useTranslateService.ts: -------------------------------------------------------------------------------- 1 | import {useAppDispatch, useAppSelector} from './redux' 2 | import {useEffect} from 'react' 3 | import {clearTransResults} from '../redux/envReducer' 4 | import {useInterval, useMemoizedFn} from 'ahooks' 5 | import useTranslate from './useTranslate' 6 | 7 | /** 8 | * Service是单例,类似后端的服务概念 9 | */ 10 | const useTranslateService = () => { 11 | const dispatch = useAppDispatch() 12 | const autoTranslate = useAppSelector(state => state.env.autoTranslate) 13 | const data = useAppSelector(state => state.env.data) 14 | const taskIds = useAppSelector(state => state.env.taskIds) 15 | const curIdx = useAppSelector(state => state.env.curIdx) 16 | const {getFetch, addTask, getTask} = useTranslate() 17 | 18 | // data变化时清空翻译结果 19 | useEffect(() => { 20 | dispatch(clearTransResults()) 21 | console.debug('清空翻译结果') 22 | }, [data, dispatch]) 23 | 24 | // autoTranslate开启时立即查询 25 | const addTaskNow = useMemoizedFn(() => { 26 | addTask(curIdx??0).catch(console.error) 27 | }) 28 | useEffect(() => { 29 | if (autoTranslate) { 30 | addTaskNow() 31 | console.debug('立即查询翻译') 32 | } 33 | }, [autoTranslate, addTaskNow]) 34 | 35 | // 每3秒检测翻译 36 | useInterval(async () => { 37 | if (autoTranslate) { 38 | const fetchStartIdx = getFetch() 39 | if (fetchStartIdx != null) { 40 | await addTask(fetchStartIdx) 41 | } 42 | } 43 | }, 3000) 44 | 45 | // 每0.5秒检测获取结果 46 | useInterval(async () => { 47 | if (taskIds != null) { 48 | for (const taskId of taskIds) { 49 | await getTask(taskId) 50 | } 51 | } 52 | }, 500) 53 | } 54 | 55 | export default useTranslateService 56 | -------------------------------------------------------------------------------- /src/components/CompactSegmentItem.tsx: -------------------------------------------------------------------------------- 1 | import React, {useMemo} from 'react' 2 | import {useAppSelector} from '../hooks/redux' 3 | import {getDisplay, getTransText} from '../utils/bizUtil' 4 | import classNames from 'classnames' 5 | 6 | const CompactSegmentItem = (props: { 7 | item: TranscriptItem 8 | idx: number 9 | isIn: boolean 10 | last: boolean 11 | moveCallback: (event: any) => void 12 | move2Callback: (event: any) => void 13 | }) => { 14 | const {item, idx, last, isIn, moveCallback, move2Callback} = props 15 | const transResult = useAppSelector(state => state.env.transResults[idx]) 16 | const envData = useAppSelector(state => state.env.envData) 17 | const fontSize = useAppSelector(state => state.env.envData.fontSize) 18 | const autoTranslate = useAppSelector(state => state.env.autoTranslate) 19 | const transText = useMemo(() => getTransText(transResult, envData.hideOnDisableAutoTranslate, autoTranslate), [autoTranslate, envData.hideOnDisableAutoTranslate, transResult]) 20 | const display = useMemo(() => getDisplay(envData.transDisplay, item.content, transText), [envData.transDisplay, item.content, transText]) 21 | 22 | return
23 | 24 | {display.main} 25 | {display.sub && ({display.sub})} 26 | {!last && ','} 27 |
28 | } 29 | 30 | export default CompactSegmentItem 31 | -------------------------------------------------------------------------------- /src/utils/search.ts: -------------------------------------------------------------------------------- 1 | import * as JsSearch from 'js-search' 2 | import {uniq} from 'lodash-es' 3 | import {getWords, getWordsPinyin} from './pinyinUtil' 4 | 5 | const tokenize = (maxLength: number, content: string, options?: SearchOptions) => { 6 | const result: string[] = [] 7 | 8 | // 最大长度 9 | if (content.length > maxLength) { 10 | content = content.substring(0, maxLength) 11 | } 12 | result.push(...getWords(content)) 13 | // check cn 14 | if (options?.cnSearchEnabled) { 15 | result.push(...getWordsPinyin(content)) 16 | } 17 | 18 | // console.debug('[Search] tokenize:', str, '=>', result) 19 | 20 | return uniq(result) 21 | } 22 | 23 | export interface SearchOptions { 24 | cnSearchEnabled?: boolean 25 | } 26 | 27 | export const Search = (uidFieldName: string, index: string, maxLength: number, options?: SearchOptions) => { 28 | let searchRef: JsSearch.Search | undefined// 搜索器 29 | 30 | /** 31 | * 重置索引 32 | */ 33 | const reset = (documents?: Object[]) => { 34 | // 搜索器 35 | searchRef = new JsSearch.Search(uidFieldName) 36 | searchRef.tokenizer = { 37 | tokenize: (str) => { 38 | return tokenize(maxLength, str, options) 39 | } 40 | } 41 | searchRef.addIndex(index) 42 | 43 | // 检测添加文档 44 | if (documents != null) { 45 | searchRef.addDocuments(documents) 46 | } 47 | } 48 | 49 | /** 50 | * 添加文档 51 | */ 52 | const add = (document: Object) => { 53 | searchRef?.addDocument(document) 54 | } 55 | 56 | /** 57 | * 搜索 58 | * @return 未去重 59 | */ 60 | const search = (text: string) => { 61 | return searchRef?.search(text.toLowerCase()) 62 | } 63 | 64 | return {reset, add, search} 65 | } 66 | -------------------------------------------------------------------------------- /src/components/NormalSegmentItem.tsx: -------------------------------------------------------------------------------- 1 | import React, {useMemo} from 'react' 2 | import {formatTime} from '../utils/util' 3 | import {useAppSelector} from '../hooks/redux' 4 | import {getDisplay, getTransText} from '../utils/bizUtil' 5 | import classNames from 'classnames' 6 | 7 | const NormalSegmentItem = (props: { 8 | item: TranscriptItem 9 | idx: number 10 | isIn: boolean 11 | moveCallback: (event: any) => void 12 | move2Callback: (event: any) => void 13 | }) => { 14 | const {item, idx, isIn, moveCallback, move2Callback} = props 15 | const transResult = useAppSelector(state => state.env.transResults[idx]) 16 | const envData = useAppSelector(state => state.env.envData) 17 | const fontSize = useAppSelector(state => state.env.envData.fontSize) 18 | const autoTranslate = useAppSelector(state => state.env.autoTranslate) 19 | const transText = useMemo(() => getTransText(transResult, envData.hideOnDisableAutoTranslate, autoTranslate), [autoTranslate, envData.hideOnDisableAutoTranslate, transResult]) 20 | const display = useMemo(() => getDisplay(envData.transDisplay, item.content, transText), [envData.transDisplay, item.content, transText]) 21 | 22 | return
24 |
{formatTime(item.from)}
25 |
26 |
{display.main}
27 | {display.sub &&
{display.sub}
} 28 |
29 |
30 | } 31 | 32 | export default NormalSegmentItem 33 | -------------------------------------------------------------------------------- /manifest.config.ts: -------------------------------------------------------------------------------- 1 | import {defineManifest} from '@crxjs/vite-plugin' 2 | // @ts-ignore 3 | import packageJson from './package.json' 4 | 5 | const {version} = packageJson 6 | 7 | // Convert from Semver (example: 0.1.0-beta6) 8 | const [major, minor, patch, label = '0'] = version 9 | // can only contain digits, dots, or dash 10 | .replace(/[^\d.-]+/g, '') 11 | // split into version parts 12 | .split(/[.-]/) 13 | 14 | export default defineManifest(async (env) => ({ 15 | "name": '__MSG_appName__', 16 | "description": '__MSG_appDescription__', 17 | "version": `${major}.${minor}.${patch}`, 18 | "default_locale": "zh_CN", 19 | "manifest_version": 3, 20 | "permissions": [ 21 | "sidePanel", 22 | "storage", 23 | ], 24 | "host_permissions": [ 25 | "http://localhost/*", 26 | "http://127.0.0.1/*" 27 | ], 28 | "background": { 29 | "service_worker": "src/chrome/background.ts", 30 | "type": "module" 31 | }, 32 | "options_page": "options.html", 33 | "content_scripts": [ 34 | { 35 | "matches": ["https://*.bilibili.com/*"], 36 | "js": ["src/inject/inject.ts"] 37 | } 38 | ], 39 | "icons": { 40 | "16": "favicon-16x16.png", 41 | "32": "favicon-32x32.png", 42 | "48": "favicon-48x48.png", 43 | "128": "favicon-128x128.png" 44 | }, 45 | "action": { 46 | // "default_popup": "popup.html", 47 | "default_icon": { 48 | "16": "favicon-16x16.png", 49 | "32": "favicon-32x32.png", 50 | "48": "favicon-48x48.png", 51 | "128": "favicon-128x128.png" 52 | } 53 | }, 54 | "web_accessible_resources": [ 55 | { 56 | "matches": ["https://*.bilibili.com/*"], 57 | "resources": [ 58 | "index.html", 59 | ], 60 | "use_dynamic_url": false 61 | } 62 | ] 63 | })) 64 | -------------------------------------------------------------------------------- /src/hooks/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import {useCallback, useEffect, useMemo, useRef, useState} from 'react' 2 | import {useAsyncEffect, useMemoizedFn} from 'ahooks/es' 3 | import {cloneDeep, isEqual} from 'lodash-es' 4 | import {getStorage, IStorage, StorageType} from '../utils/storage' 5 | 6 | const useLocalStorage = (type: StorageType | IStorage, key: string, data: T, onLoad: (data?: T) => void) => { 7 | const onLoadMemorized = useMemoizedFn(onLoad) 8 | const storage = useMemo(() => typeof type === 'string'?getStorage(type):type, [type]) 9 | const {setStore, getStore} = storage 10 | const [ready, setReady] = useState(false) 11 | const prevData = useRef() 12 | 13 | // 存数据 14 | useEffect(() => { 15 | if (ready) { 16 | if (!isEqual(prevData.current, data)) { 17 | prevData.current = cloneDeep(data) 18 | setStore(key, JSON.stringify(data)).catch(console.error) 19 | console.debug(`[local][${key}]存数据: `, data) 20 | } else { 21 | console.debug(`[local][${key}]数据未变化,存数据取消!`) 22 | } 23 | } 24 | }, [data, key, ready, setStore]) 25 | 26 | // 刷新数据 27 | const refresh = useCallback(async () => { 28 | const s = await getStore(key) 29 | let savedData: T | undefined 30 | if (s) { 31 | try { 32 | savedData = JSON.parse(s) 33 | } catch (e) { 34 | console.error(`[local][${key}]格式解析异常: `, s) 35 | } 36 | } 37 | prevData.current = cloneDeep(savedData) 38 | onLoadMemorized(savedData) 39 | console.debug(`[local][${key}]读数据: `, savedData) 40 | }, [onLoadMemorized, getStore, key]) 41 | 42 | // 读数据 43 | useAsyncEffect(async () => { 44 | await refresh() 45 | // 读取完毕 46 | setReady(true) 47 | }, [refresh]) 48 | 49 | return {ready, refresh} 50 | } 51 | 52 | export default useLocalStorage 53 | -------------------------------------------------------------------------------- /src/pages/MainPage.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useContext, useEffect} from 'react' 2 | import {useAppDispatch, useAppSelector} from '../hooks/redux' 3 | import Header from '../components/Header' 4 | import Body from '../components/Body' 5 | import useSubtitleService from '../hooks/useSubtitleService' 6 | import {EVENT_EXPAND} from '../consts/const' 7 | import {EventBusContext} from '../Router' 8 | import useTranslateService from '../hooks/useTranslateService' 9 | import {setTheme} from '../utils/bizUtil' 10 | import useSearchService from '../hooks/useSearchService' 11 | import {setFold} from '../redux/envReducer' 12 | import { useMessage } from '@/hooks/useMessageService' 13 | 14 | function App() { 15 | const dispatch = useAppDispatch() 16 | const fold = useAppSelector(state => state.env.fold) 17 | const envData = useAppSelector(state => state.env.envData) 18 | const eventBus = useContext(EventBusContext) 19 | const totalHeight = useAppSelector(state => state.env.totalHeight) 20 | const {sendInject} = useMessage(!!envData.sidePanel) 21 | 22 | const foldCallback = useCallback(() => { 23 | dispatch(setFold(!fold)) 24 | sendInject(null, 'FOLD', {fold: !fold}) 25 | }, [dispatch, fold, sendInject]) 26 | 27 | // handle event 28 | eventBus.useSubscription((event: any) => { 29 | if (event.type === EVENT_EXPAND) { 30 | if (fold) { 31 | foldCallback() 32 | } 33 | } 34 | }) 35 | 36 | // theme改变时,设置主题 37 | useEffect(() => { 38 | setTheme(envData.theme) 39 | }, [envData.theme]) 40 | 41 | useSubtitleService() 42 | useTranslateService() 43 | useSearchService() 44 | 45 | return
48 |
49 | {!fold && } 50 |
51 | } 52 | 53 | export default App 54 | -------------------------------------------------------------------------------- /src/hooks/useSearchService.ts: -------------------------------------------------------------------------------- 1 | import {useAppDispatch, useAppSelector} from './redux' 2 | import {useEffect, useMemo} from 'react' 3 | import {setSearchResult, setSearchText, } from '../redux/envReducer' 4 | import {Search} from '../utils/search' 5 | 6 | interface Document { 7 | idx: number 8 | s: string // searchKeys 9 | } 10 | 11 | const useSearchService = () => { 12 | const dispatch = useAppDispatch() 13 | 14 | const envData = useAppSelector(state => state.env.envData) 15 | const data = useAppSelector(state => state.env.data) 16 | const searchText = useAppSelector(state => state.env.searchText) 17 | 18 | const {reset, search} = useMemo(() => Search('idx', 's', 256, { 19 | cnSearchEnabled: envData.cnSearchEnabled 20 | }), [envData.cnSearchEnabled]) // 搜索实例 21 | 22 | // reset search 23 | useEffect(() => { 24 | if (!envData.searchEnabled) { 25 | return 26 | } 27 | const startTime = Date.now() 28 | const docs: Document[] = [] 29 | for (const item of data?.body??[]) { 30 | docs.push({ 31 | idx: item.idx, 32 | s: item.content, 33 | }) 34 | } 35 | reset(docs) 36 | // 清空搜索文本 37 | dispatch(setSearchText('')) 38 | // 日志 39 | const endTime = Date.now() 40 | console.debug(`[Search]reset ${docs.length} docs, cost ${endTime-startTime}ms`) 41 | }, [data?.body, dispatch, envData.searchEnabled, reset]) 42 | 43 | // search text 44 | useEffect(() => { 45 | const searchResult: Record = {} 46 | 47 | if (envData.searchEnabled && searchText) { 48 | // @ts-expect-error 49 | const documents: Document[] | undefined = search(searchText) 50 | if (documents != null) { 51 | for (const document of documents) { 52 | searchResult[''+document.idx] = true 53 | } 54 | } 55 | } 56 | 57 | dispatch(setSearchResult(searchResult)) 58 | }, [dispatch, envData.searchEnabled, search, searchText]) 59 | } 60 | 61 | export default useSearchService 62 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useMemo} from 'react' 2 | import 'tippy.js/dist/tippy.css' 3 | import {useAppDispatch, useAppSelector} from './hooks/redux' 4 | import {setEnvData, setEnvReady, setTempData, setTempReady} from './redux/envReducer' 5 | import {cloneDeep} from 'lodash-es' 6 | import {STORAGE_ENV, STORAGE_TEMP} from './consts/const' 7 | import OptionsPage from './pages/OptionsPage' 8 | import {handleJson} from './utils/util' 9 | import {Toaster} from 'react-hot-toast' 10 | import useMessageService from './hooks/useMessageService' 11 | import MainPage from './pages/MainPage' 12 | import useLocalStorage from './hooks/useLocalStorage' 13 | 14 | function App() { 15 | const dispatch = useAppDispatch() 16 | const envData = useAppSelector(state => state.env.envData) 17 | const tempData = useAppSelector(state => state.env.tempData) 18 | const path = useAppSelector(state => state.env.path) 19 | const envReady = useAppSelector(state => state.env.envReady) 20 | const tempReady = useAppSelector(state => state.env.tempReady) 21 | 22 | // env数据 23 | const savedEnvData = useMemo(() => { 24 | return handleJson(cloneDeep(envData)) as EnvData 25 | }, [envData]) 26 | const onLoadEnv = useCallback((data?: EnvData) => { 27 | if (data != null) { 28 | dispatch(setEnvData(data)) 29 | } 30 | dispatch(setEnvReady()) 31 | }, [dispatch]) 32 | useLocalStorage('chrome_client', STORAGE_ENV, savedEnvData, onLoadEnv) 33 | 34 | // temp数据 35 | const savedTempData = useMemo(() => { 36 | return handleJson(cloneDeep(tempData)) as TempData 37 | }, [tempData]) 38 | const onLoadTemp = useCallback((data?: TempData) => { 39 | if (data != null) { 40 | dispatch(setTempData(data)) 41 | } 42 | dispatch(setTempReady()) 43 | }, [dispatch]) 44 | useLocalStorage('chrome_client', STORAGE_TEMP, savedTempData, onLoadTemp) 45 | 46 | // services 47 | useMessageService() 48 | 49 | return
50 | 51 | {path === 'app' && } 52 | {path === 'options' && envReady && tempReady && } 53 |
54 | } 55 | 56 | export default App 57 | -------------------------------------------------------------------------------- /src/utils/Waiter.ts: -------------------------------------------------------------------------------- 1 | interface Context { 2 | resolve: (value: T) => void 3 | reject: (reason?: any) => void 4 | } 5 | 6 | export interface SuccessResult { 7 | finished: true 8 | data: T 9 | } 10 | export interface FailResult { 11 | finished: false 12 | } 13 | export type Result = SuccessResult | FailResult 14 | 15 | /** 16 | * 等待器 17 | */ 18 | export default class Waiter { 19 | waitingList: Array> = [] 20 | 21 | check: () => Result 22 | 23 | finished = false 24 | success = false 25 | data?: T 26 | 27 | constructor(check: () => Result, checkInterval: number, timeout: number) { 28 | this.check = check 29 | 30 | // timer 31 | const start = Date.now() 32 | const timerId = setInterval(() => { 33 | if (!this.tick()) { 34 | // check timeout 35 | if (Date.now() - start > timeout) { 36 | this.finished = true 37 | this.success = false 38 | this.data = undefined 39 | // reject all 40 | this.waitingList.forEach(e => e.reject('timeout')) 41 | this.waitingList = [] 42 | // clear interval 43 | clearInterval(timerId) 44 | } 45 | } else { 46 | // clear interval 47 | clearInterval(timerId) 48 | } 49 | }, checkInterval) 50 | } 51 | 52 | private tick() { 53 | if (this.finished) { 54 | return true 55 | } 56 | const result = this.check() 57 | if (result.finished) { 58 | this.finished = true 59 | this.success = true 60 | this.data = result.data 61 | // execute waiting list first 62 | if (this.waitingList.length > 0) { 63 | this.waitingList.forEach(e => e.resolve(this.data as T)) 64 | this.waitingList = [] 65 | } 66 | } 67 | return result.finished 68 | } 69 | 70 | public async wait() { 71 | if (this.tick()) { 72 | return await Promise.resolve(this.data as T) 73 | } else { 74 | // add to waiting list 75 | return await new Promise((resolve, reject) => { 76 | const context: Context = { 77 | resolve, 78 | reject, 79 | } 80 | this.waitingList.push(context) 81 | }) 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/components/RateExtension.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { FaStar } from 'react-icons/fa' 3 | import { IoMdClose } from 'react-icons/io' 4 | import { setTempData } from '../redux/envReducer' 5 | import { useAppDispatch, useAppSelector } from '../hooks/redux' 6 | import { openUrl } from '../utils/env_util' 7 | import { isEdgeBrowser } from '../utils/util' 8 | 9 | const RateExtension: React.FC = () => { 10 | const dispatch = useAppDispatch() 11 | const [isHovered, setIsHovered] = useState(false) 12 | const reviewed = useAppSelector(state => state.env.tempData.reviewed) 13 | 14 | const handleRateClick = () => { 15 | dispatch(setTempData({ 16 | reviewed: true 17 | })) 18 | // Chrome Web Store URL for your extension 19 | if (isEdgeBrowser()) { 20 | openUrl('https://microsoftedge.microsoft.com/addons/detail/lignnlhlpiefmcjkdkmfjdckhlaiajan') 21 | } else { 22 | openUrl('https://chromewebstore.google.com/webstore/detail/bciglihaegkdhoogebcdblfhppoilclp/reviews') 23 | } 24 | } 25 | 26 | if (reviewed === true || reviewed === undefined) return null 27 | 28 | return ( 29 |
30 | 40 |

喜欢这个扩展吗?

41 |

如果觉得有用,请给我们评分!

42 | 54 |
55 | ) 56 | } 57 | 58 | export default RateExtension 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "bilibili-subtitle", 4 | "version": "1.14.1", 5 | "type": "module", 6 | "description": "哔哩哔哩字幕列表", 7 | "main": "index.js", 8 | "scripts": { 9 | "dev": "vite", 10 | "build": "tsc && vite build && node fix.cjs", 11 | "fix": "eslint --fix --quiet ." 12 | }, 13 | "author": "IndieKKY", 14 | "license": "MIT", 15 | "dependencies": { 16 | "@crxjs/vite-plugin": "^1.0.14", 17 | "@logto/react": "1.0.0-beta.13", 18 | "@popperjs/core": "^2.11.6", 19 | "@reduxjs/toolkit": "^1.8.5", 20 | "@tippyjs/react": "^4.2.6", 21 | "ahooks": "^3.7.1", 22 | "classnames": "^2.3.2", 23 | "daisyui": "^2.42.1", 24 | "dayjs": "^1.11.13", 25 | "js-search": "^2.0.0", 26 | "less": "^4.1.3", 27 | "lodash-es": "^4.17.21", 28 | "pako": "^2.1.0", 29 | "qs": "^6.11.0", 30 | "react": "^18.2.0", 31 | "react-dom": "^18.2.0", 32 | "react-hot-toast": "^2.4.0", 33 | "react-icons": "^4.4.0", 34 | "react-markdown": "^8.0.3", 35 | "react-popper": "^2.3.0", 36 | "react-redux": "^8.0.2", 37 | "react-slider": "^2.0.4", 38 | "remark-gfm": "^3.0.1", 39 | "tailwind-scrollbar-hide": "^1.1.7", 40 | "tiny-pinyin": "^1.3.2", 41 | "tippy.js": "^6.3.7", 42 | "uuid": "^9.0.0" 43 | }, 44 | "devDependencies": { 45 | "@tailwindcss/line-clamp": "^0.4.2", 46 | "@tailwindcss/typography": "^0.5.8", 47 | "@types/chrome": "^0.0.277", 48 | "@types/js-search": "^1.4.0", 49 | "@types/lodash-es": "^4.17.6", 50 | "@types/node": "^20.8.10", 51 | "@types/pako": "^2.0.0", 52 | "@types/qs": "^6.9.7", 53 | "@types/react": "^18.0.20", 54 | "@types/react-dom": "^18.0.6", 55 | "@types/react-slider": "^1.3.1", 56 | "@types/uuid": "^8.3.4", 57 | "@typescript-eslint/eslint-plugin": "^5.37.0", 58 | "@typescript-eslint/parser": "^5.37.0", 59 | "@vitejs/plugin-react": "^2.2.0", 60 | "autoprefixer": "^10.4.13", 61 | "eslint": "8.22.0", 62 | "eslint-config-standard": "^17.0.0", 63 | "eslint-config-standard-with-typescript": "^23.0.0", 64 | "eslint-plugin-import": "^2.26.0", 65 | "eslint-plugin-n": "^15.2.5", 66 | "eslint-plugin-promise": "^6.0.1", 67 | "eslint-plugin-react": "^7.31.8", 68 | "eslint-plugin-react-hooks": "^4.6.0", 69 | "postcss": "^8.4.19", 70 | "rollup-plugin-visualizer": "^5.8.3", 71 | "tailwindcss": "^3.2.4", 72 | "typescript": "^4.8.3", 73 | "vite": "^3.2.7" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/message/layer2/useMessaging.ts: -------------------------------------------------------------------------------- 1 | import { msgWaiter } from './useMessagingService' 2 | import { useCallback, useState } from 'react' 3 | import Layer1Protocol from '../layer1/Layer1Protocol' 4 | import { TAG_TARGET_INJECT } from '../const' 5 | import { sendHandshakeFromApp } from '../messagingUtil' 6 | import { ExtensionMessage, InjectMessage, L2ReqMsg, L2ResMsg, MessagingExtensionMessages } from '../typings' 7 | import { handleRes } from '../util' 8 | 9 | const useMessaging = (defaultUsePort: boolean) => { 10 | const [disconnected, setDisconnected] = useState(false) 11 | 12 | const sendExtension = useCallback(async (usePort: boolean | null, method: K, params?: Extract['params']): Promise['return']> => { 13 | if (usePort === null) { 14 | usePort = defaultUsePort 15 | } 16 | if (usePort) { 17 | // wait 18 | const pmh = await msgWaiter.wait() 19 | if (pmh.disconnected) { 20 | // console.info('pmh reconnect...') 21 | // pmh.reconnect() 22 | // 初始化 23 | // sendHandshakeFromApp(pmh) 24 | setDisconnected(true) 25 | throw new Error('disconnected') 26 | } 27 | // send message 28 | console.debug('pmh_sendMessage:', method, params) 29 | return handleRes(await pmh.sendMessage({ 30 | from: 'app', 31 | target: 'extension', 32 | method, 33 | params: params ?? {}, 34 | })) 35 | } else { 36 | // send message 37 | const res = await chrome.runtime.sendMessage({ 38 | from: 'app', 39 | target: 'extension', 40 | method, 41 | params: params?? {}, 42 | }) 43 | return handleRes(res) 44 | } 45 | }, [defaultUsePort]) 46 | 47 | const sendInject = useCallback(async (usePort: boolean | null, method: K, params?: Extract['params']): Promise['return']> => { 48 | return await sendExtension(usePort, '_ROUTE' as any, { 49 | usePort: false, // 路由到inject不需要port 50 | tag: TAG_TARGET_INJECT, 51 | method, 52 | params: params ?? {}, 53 | }) 54 | }, [sendExtension]) 55 | 56 | return { 57 | sendExtension, 58 | sendInject, 59 | disconnected 60 | } 61 | } 62 | 63 | export default useMessaging 64 | -------------------------------------------------------------------------------- /src/hooks/useKeyService.ts: -------------------------------------------------------------------------------- 1 | import {useEffect} from 'react' 2 | import {useMemoizedFn} from 'ahooks/es' 3 | import {useAppDispatch, useAppSelector} from './redux' 4 | import useSubtitle from './useSubtitle' 5 | import {setInputting} from '../redux/envReducer' 6 | 7 | const useKeyService = () => { 8 | const dispatch = useAppDispatch() 9 | const inputting = useAppSelector(state => state.env.inputting) 10 | const curIdx = useAppSelector(state => state.env.curIdx) 11 | const data = useAppSelector(state => state.env.data) 12 | const {move} = useSubtitle() 13 | 14 | // 输入中 15 | useEffect(() => { 16 | const onInputtingStart = (e: CompositionEvent) => { 17 | dispatch(setInputting(true)) 18 | } 19 | const onInputtingEnd = (e: CompositionEvent) => { 20 | dispatch(setInputting(false)) 21 | } 22 | 23 | document.addEventListener('compositionstart', onInputtingStart) 24 | document.addEventListener('compositionend', onInputtingEnd) 25 | return () => { 26 | document.removeEventListener('compositionstart', onInputtingStart) 27 | document.removeEventListener('compositionend', onInputtingEnd) 28 | } 29 | }, [dispatch]) 30 | 31 | const onKeyDown = useMemoizedFn((e: KeyboardEvent) => { 32 | // 当前在输入中(如中文输入法) 33 | if (inputting) { 34 | return 35 | } 36 | 37 | // 有按其他控制键时,不触发 38 | if (e.ctrlKey || e.metaKey || e.shiftKey) { 39 | return 40 | } 41 | 42 | let cursorInInput = false 43 | if (document.activeElement != null) { 44 | const tagName = document.activeElement.tagName 45 | if (tagName === 'INPUT' || tagName === 'TEXTAREA') { 46 | cursorInInput = true 47 | } 48 | } 49 | let prevent = false 50 | 51 | // up arrow 52 | if (e.key === 'ArrowUp') { 53 | if (curIdx && (data != null) && !cursorInInput) { 54 | prevent = true 55 | const newCurIdx = Math.max(curIdx - 1, 0) 56 | move(data.body[newCurIdx].from, false) 57 | } 58 | } 59 | // down arrow 60 | if (e.key === 'ArrowDown') { 61 | if (curIdx !== undefined && (data != null) && !cursorInInput) { 62 | prevent = true 63 | const newCurIdx = Math.min(curIdx + 1, data.body.length - 1) 64 | move(data.body[newCurIdx].from, false) 65 | } 66 | } 67 | 68 | // 阻止默认事件 69 | if (prevent) { 70 | e.preventDefault() 71 | e.stopPropagation() 72 | } 73 | }) 74 | 75 | // 检测快捷键 76 | useEffect(() => { 77 | document.addEventListener('keydown', onKeyDown) 78 | return () => { 79 | document.removeEventListener('keydown', onKeyDown) 80 | } 81 | }, [onKeyDown]) 82 | } 83 | 84 | export default useKeyService 85 | -------------------------------------------------------------------------------- /src/components/SegmentItem.tsx: -------------------------------------------------------------------------------- 1 | import React, {useCallback, useEffect, useMemo, useRef} from 'react' 2 | import {useAppDispatch, useAppSelector} from '../hooks/redux' 3 | import useSubtitle from '../hooks/useSubtitle' 4 | import {setCheckAutoScroll, setCurOffsetTop, setNeedScroll} from '../redux/envReducer' 5 | import NormalSegmentItem from './NormalSegmentItem' 6 | import CompactSegmentItem from './CompactSegmentItem' 7 | 8 | const SegmentItem = (props: { 9 | bodyRef: any 10 | item: TranscriptItem 11 | idx: number 12 | isIn: boolean 13 | needScroll?: boolean 14 | last: boolean 15 | }) => { 16 | const {bodyRef, item, idx, isIn, needScroll, last} = props 17 | const dispatch = useAppDispatch() 18 | const ref = useRef() 19 | const {move} = useSubtitle() 20 | 21 | const compact = useAppSelector(state => state.env.tempData.compact) 22 | const searchText = useAppSelector(state => state.env.searchText) 23 | const searchResult = useAppSelector(state => state.env.searchResult) 24 | const display = useMemo(() => { 25 | if (searchText) { 26 | return searchResult[item.idx+''] ? 'inline' : 'none' 27 | } else { 28 | return 'inline' 29 | } 30 | }, [item.idx, searchResult, searchText]) 31 | 32 | const moveCallback = useCallback((event: any) => { 33 | if (event.altKey) { // 复制 34 | navigator.clipboard.writeText(item.content).catch(console.error) 35 | } else { 36 | move(item.from, false) 37 | } 38 | }, [item.content, item.from, move]) 39 | 40 | const move2Callback = useCallback((event: any) => { 41 | if (event.altKey) { // 复制 42 | navigator.clipboard.writeText(item.content).catch(console.error) 43 | } else { 44 | move(item.from, true) 45 | } 46 | }, [item.content, item.from, move]) 47 | 48 | // 检测需要滚动进入视野 49 | useEffect(() => { 50 | if (needScroll) { 51 | bodyRef.current.scrollTop = ref.current.offsetTop - bodyRef.current.offsetTop - 40 52 | dispatch(setNeedScroll(false)) 53 | } 54 | }, [dispatch, needScroll, bodyRef]) 55 | 56 | // 进入时更新当前offsetTop 57 | useEffect(() => { 58 | if (isIn) { 59 | dispatch(setCurOffsetTop(ref.current.offsetTop)) 60 | dispatch(setCheckAutoScroll(true)) 61 | } 62 | }, [dispatch, isIn]) 63 | 64 | return 67 | {compact 68 | ? 76 | : 77 | 84 | } 85 | 86 | } 87 | 88 | export default SegmentItem 89 | -------------------------------------------------------------------------------- /src/message-typings.d.ts: -------------------------------------------------------------------------------- 1 | import {ExtensionMessage, InjectMessage, AppMessage} from './message' 2 | 3 | // extension 4 | interface ExtensionCloseSidePanelMessage extends ExtensionMessage { 5 | method: 'CLOSE_SIDE_PANEL' 6 | } 7 | 8 | interface ExtensionAddTaskMessage extends ExtensionMessage<{ taskDef: TaskDef }, Task> { 9 | method: 'ADD_TASK' 10 | } 11 | 12 | interface ExtensionGetTaskMessage extends ExtensionMessage<{ taskId: string }, { 13 | code: 'ok' 14 | task: Task 15 | } | { 16 | code: 'not_found' 17 | }> { 18 | method: 'GET_TASK' 19 | } 20 | 21 | interface ExtensionShowFlagMessage extends ExtensionMessage<{ show: boolean }> { 22 | method: 'SHOW_FLAG' 23 | } 24 | 25 | interface ExtensionGetTabIdMessage extends ExtensionMessage<{ show: boolean }> { 26 | method: 'GET_TAB_ID' 27 | } 28 | 29 | export type AllExtensionMessages = ExtensionGetTabIdMessage | ExtensionCloseSidePanelMessage | ExtensionAddTaskMessage | ExtensionGetTaskMessage | ExtensionShowFlagMessage 30 | 31 | // inject 32 | interface InjectToggleDisplayMessage extends InjectMessage<{}> { 33 | method: 'TOGGLE_DISPLAY' 34 | } 35 | 36 | interface InjectFoldMessage extends InjectMessage<{ fold: boolean }> { 37 | method: 'FOLD' 38 | } 39 | 40 | interface InjectMoveMessage extends InjectMessage<{ time: number, togglePause: boolean }> { 41 | method: 'MOVE' 42 | } 43 | 44 | interface InjectGetSubtitleMessage extends InjectMessage<{ info: any }> { 45 | method: 'GET_SUBTITLE' 46 | } 47 | 48 | interface InjectGetVideoStatusMessage extends InjectMessage<{}> { 49 | method: 'GET_VIDEO_STATUS' 50 | } 51 | 52 | interface InjectGetVideoElementInfoMessage extends InjectMessage<{}> { 53 | method: 'GET_VIDEO_ELEMENT_INFO' 54 | } 55 | 56 | interface InjectRefreshVideoInfoMessage extends InjectMessage<{ force: boolean }> { 57 | method: 'REFRESH_VIDEO_INFO' 58 | } 59 | 60 | interface InjectUpdateTransResultMessage extends InjectMessage<{ result: string }> { 61 | method: 'UPDATE_TRANS_RESULT' 62 | } 63 | 64 | interface InjectHideTransMessage extends InjectMessage<{}> { 65 | method: 'HIDE_TRANS' 66 | } 67 | 68 | interface InjectPlayMessage extends InjectMessage<{ play: boolean }> { 69 | method: 'PLAY' 70 | } 71 | 72 | interface InjectDownloadAudioMessage extends InjectMessage<{}> { 73 | method: 'DOWNLOAD_AUDIO' 74 | } 75 | 76 | export type AllInjectMessages = InjectToggleDisplayMessage | InjectFoldMessage | InjectMoveMessage | InjectGetSubtitleMessage | InjectGetVideoStatusMessage | InjectGetVideoElementInfoMessage | InjectRefreshVideoInfoMessage | InjectUpdateTransResultMessage | InjectHideTransMessage | InjectPlayMessage | InjectDownloadAudioMessage 77 | 78 | // app 79 | interface AppSetInfosMessage extends AppMessage<{ infos: any }> { 80 | method: 'SET_INFOS' 81 | } 82 | 83 | interface AppSetVideoInfoMessage extends AppMessage<{ url: string, title: string, aid: number | null, ctime: number | null, author?: string, pages: any, chapters: any, infos: any }> { 84 | method: 'SET_VIDEO_INFO' 85 | } 86 | 87 | export type AllAPPMessages = AppSetInfosMessage | AppSetVideoInfoMessage 88 | -------------------------------------------------------------------------------- /src/message/layer2/useMessagingService.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react' 2 | import Waiter from '../../utils/Waiter' 3 | import Layer1Protocol from '../layer1/Layer1Protocol' 4 | import { sendHandshakeFromApp } from '../messagingUtil' 5 | import { useAsyncEffect } from 'ahooks' 6 | import { AppMessage, L2ReqMsg, L2ResMsg, MethodContext } from '../typings' 7 | 8 | const debug = (...args: any[]) => { 9 | console.debug('[App Messaging]', ...args) 10 | } 11 | 12 | let l1protocolInit: boolean = false 13 | let l1protocol: Layer1Protocol | undefined 14 | // let postInjectMessage: (method: string, params: PostMessagePayload) => Promise | undefined 15 | 16 | export const msgWaiter = new Waiter>(() => ({ 17 | finished: l1protocolInit, 18 | data: l1protocol!, 19 | }), 100, 15000) 20 | 21 | const useMessagingService = (usePort: boolean, methodsFunc?: () => { 22 | [K in AllAPPMessagesType['method']]: (params: Extract['params'], context: MethodContext) => Promise 23 | }) => { 24 | const messageHandler = useCallback(async (req: L2ReqMsg): Promise => { 25 | debug(`[${req.from}] ${req.method}`, JSON.stringify(req)) 26 | 27 | const methods = methodsFunc?.() 28 | const method = methods?.[req.method as keyof typeof methods] 29 | if (method != null) { 30 | return await method(req.params, { 31 | from: req.from, 32 | event: req, 33 | }).then(data => { 34 | // debug(`${source} <= `, event.method, JSON.stringify(data)) 35 | return { 36 | code: 200, 37 | data, 38 | } 39 | }).catch(err => { 40 | console.error(err) 41 | let msg 42 | if (err instanceof Error) { 43 | msg = err.message 44 | } else if (typeof err === 'string') { 45 | msg = err 46 | } else { 47 | msg = 'error: ' + JSON.stringify(err) 48 | } 49 | return { 50 | code: 500, 51 | msg, 52 | } 53 | }) 54 | } else { 55 | return { 56 | code: 501, 57 | msg: 'Unknown method: ' + req.method, 58 | } 59 | } 60 | }, [methodsFunc]) 61 | 62 | // 普通 63 | useEffect(() => { 64 | const listener = (req: L2ReqMsg, sender: chrome.runtime.MessageSender, sendResponse: (res: L2ResMsg) => void) => { 65 | // check target 66 | if (req.target!== 'app') { 67 | return false 68 | } 69 | messageHandler(req).then(res => { 70 | sendResponse(res) 71 | }) 72 | return true 73 | } 74 | chrome.runtime.onMessage.addListener(listener) 75 | return () => { 76 | chrome.runtime.onMessage.removeListener(listener) 77 | } 78 | }, [messageHandler]) 79 | 80 | // port 81 | useAsyncEffect(async () => { 82 | if (messageHandler && usePort) { 83 | l1protocol = new Layer1Protocol(messageHandler) 84 | // 初始化 85 | sendHandshakeFromApp(l1protocol) 86 | l1protocolInit = true 87 | } 88 | }, [messageHandler, usePort]) 89 | } 90 | 91 | export default useMessagingService 92 | -------------------------------------------------------------------------------- /src/components/Ask.tsx: -------------------------------------------------------------------------------- 1 | import {AiOutlineCloseCircle, BsDashSquare, BsPlusSquare, FaQuestion} from 'react-icons/all' 2 | import classNames from 'classnames' 3 | import Markdown from '../components/Markdown' 4 | import React, {useCallback} from 'react' 5 | import {delAskInfo, mergeAskInfo, setTempData} from '../redux/envReducer' 6 | import {useAppDispatch, useAppSelector} from '../hooks/redux' 7 | import toast from 'react-hot-toast' 8 | import useTranslate from '../hooks/useTranslate' 9 | 10 | const Ask = (props: { 11 | ask: AskInfo 12 | }) => { 13 | const {ask} = props 14 | const dispatch = useAppDispatch() 15 | const envData = useAppSelector(state => state.env.envData) 16 | const fontSize = useAppSelector(state => state.env.envData.fontSize) 17 | const segments = useAppSelector(state => state.env.segments) 18 | const {addAskTask} = useTranslate() 19 | 20 | const onRegenerate = useCallback(() => { 21 | const apiKey = envData.apiKey 22 | if (apiKey) { 23 | if (segments != null && segments.length > 0) { 24 | addAskTask(ask.id, segments[0], ask.question).catch(console.error) 25 | } 26 | } else { 27 | toast.error('请先在选项页面设置ApiKey!') 28 | } 29 | }, [addAskTask, ask.id, ask.question, envData.apiKey, segments]) 30 | 31 | const onAskFold = useCallback(() => { 32 | dispatch(mergeAskInfo({ 33 | id: ask.id, 34 | fold: !ask.fold 35 | })) 36 | }, [ask, dispatch]) 37 | 38 | const onClose = useCallback(() => { 39 | dispatch(delAskInfo(ask.id)) 40 | }, [ask, dispatch]) 41 | 42 | return
43 |
44 |
45 | {ask.fold 46 | ? : 47 | } 48 |
49 | 52 |
53 | 54 | 提问 55 | 56 |
57 |
58 | {!ask.fold && ask.question &&
{ask.question}
} 59 | {!ask.fold && ask.content && 60 |
61 | 62 |
} 63 | {!ask.fold && } 66 | {!ask.fold && ask.error &&
{ask.error}
} 67 |
68 | } 69 | 70 | export default Ask 71 | -------------------------------------------------------------------------------- /src/utils/pinyinUtil.ts: -------------------------------------------------------------------------------- 1 | import pinyin from 'tiny-pinyin' 2 | import {uniq} from 'lodash-es' 3 | 4 | /** 5 | * pinyin的返回结果 6 | */ 7 | interface Ret { 8 | type: 1 | 2 | 3 9 | source: string 10 | target: string 11 | } 12 | 13 | interface Phase { 14 | pinyin: boolean 15 | list: Ret[] 16 | } 17 | 18 | /** 19 | * 获取Phase列表(中英文分离列表) 20 | */ 21 | export const getPhases = (str: string) => { 22 | const rets = pinyin.parse(str) 23 | 24 | const phases: Phase[] = [] 25 | let curPinyin_ = false 26 | let curPhase_: Ret[] = [] 27 | const addCurrentPhase = () => { 28 | if (curPhase_.length > 0) { 29 | phases.push({ 30 | pinyin: curPinyin_, 31 | list: curPhase_, 32 | }) 33 | } 34 | } 35 | 36 | // 遍历rets 37 | for (const ret of rets) { 38 | const newPinyin = ret.type === 2 39 | // 如果跟旧的pinyin类型不同,先保存旧的 40 | if (newPinyin !== curPinyin_) { 41 | addCurrentPhase() 42 | // 重置 43 | curPinyin_ = newPinyin 44 | curPhase_ = [] 45 | } 46 | // 添加新的 47 | curPhase_.push(ret) 48 | } 49 | // 最后一个 50 | addCurrentPhase() 51 | 52 | return phases 53 | } 54 | 55 | /** 56 | * 获取原子字符列表,如 tool tab 汉 字 57 | */ 58 | export const getAtoms = (str: string) => { 59 | const phases = getPhases(str) 60 | 61 | const atoms = [] 62 | for (const phase of phases) { 63 | if (phase.pinyin) { // all words 64 | atoms.push(...phase.list.map(e => e.source).filter(e => e)) 65 | } else { // split 66 | atoms.push(...(phase.list.map((e: any) => e.source).join('').match(/\w+/g)??[]).filter((e: string) => e)) 67 | } 68 | } 69 | 70 | return atoms 71 | } 72 | 73 | const fixStrs = (atoms: string[]) => { 74 | // 小写 75 | atoms = atoms.map(e => e.toLowerCase()) 76 | 77 | // 去重 78 | atoms = uniq(atoms) 79 | 80 | // 返回 81 | return atoms 82 | } 83 | 84 | export const getWords = (str: string) => { 85 | // 获取全部原子字符 86 | const atoms = getAtoms(str) 87 | // fix 88 | return fixStrs(atoms) 89 | } 90 | 91 | /** 92 | * 我的世界Minecraft => ['wodeshijie', 'deshijie', 'shijie', 'jie'] + ['wdsj', 'dsj', 'sj', 'j'] 93 | * 94 | * 1. only handle pinyin, other is ignored 95 | */ 96 | export const getWordsPinyin = (str: string) => { 97 | let result: string[] = [] 98 | 99 | for (const phase of getPhases(str)) { 100 | // only handle pinyin 101 | if (phase.pinyin) { // 我的世界 102 | // 获取全部原子字符 103 | // 我的世界 => [我, 的, 世, 界] 104 | const atoms: string[] = [] 105 | atoms.push(...phase.list.map(e => e.source).filter(e => e)) 106 | // 获取全部子串 107 | // [我, 的, 世, 界] => [我的世界, 的世界, 世界, 界] 108 | const allSubStr = [] 109 | for (let i = 0; i < atoms.length; i++) { 110 | allSubStr.push(atoms.slice(i).join('')) 111 | } 112 | // pinyin version 113 | const pinyinList = allSubStr.map((e: string) => pinyin.convertToPinyin(e)) 114 | result.push(...pinyinList) 115 | // pinyin first version 116 | const pinyinFirstList = allSubStr.map((e: string) => pinyin.parse(e).map((e: any) => e.type === 2?e.target[0]:null).filter(e => !!e).join('')) 117 | result.push(...pinyinFirstList) 118 | } 119 | } 120 | 121 | // fix 122 | result = fixStrs(result) 123 | 124 | return result 125 | } 126 | -------------------------------------------------------------------------------- /src/message/layer2/InjectMessaging.ts: -------------------------------------------------------------------------------- 1 | import { TAG_TARGET_APP } from '../const' 2 | import { ExtensionMessage, InjectMessage, AppMessage, MethodContext, MessagingExtensionMessages, L2ReqMsg, L2ResMsg } from '../typings' 3 | import { handleRes } from '../util' 4 | 5 | class InjectMessaging { 6 | defaultUsePort: boolean 7 | port?: chrome.runtime.Port 8 | // l1protocol?: Layer1Protocol 9 | // 类实例 10 | methods?: { 11 | [K in AllInjectMessagesType['method']]: (params: Extract['params'], context: MethodContext) => Promise 12 | } 13 | 14 | constructor(defaultUsePort: boolean) { 15 | this.defaultUsePort = defaultUsePort 16 | } 17 | 18 | debug = (...args: any[]) => { 19 | console.debug('[Inject Messaging]', ...args) 20 | } 21 | 22 | messageHandler = async (req: L2ReqMsg): Promise => { 23 | this.debug(`[${req.from}] ${req.method}`, JSON.stringify(req)) 24 | 25 | const method = this.methods?.[req.method as keyof typeof this.methods] 26 | if (method != null) { 27 | return await method(req.params, { 28 | from: req.from, 29 | event: req, 30 | // sender, 31 | }).then(data => { 32 | // debug(`${source} <= `, event.method, JSON.stringify(data)) 33 | return { 34 | code: 200, 35 | data, 36 | } 37 | }).catch(err => { 38 | console.error(err) 39 | let msg 40 | if (err instanceof Error) { 41 | msg = err.message 42 | } else if (typeof err === 'string') { 43 | msg = err 44 | } else { 45 | msg = 'error: ' + JSON.stringify(err) 46 | } 47 | return { 48 | code: 500, 49 | msg, 50 | } 51 | }) 52 | } else { 53 | return { 54 | code: 501, 55 | msg: 'Unknown method: ' + req.method, 56 | } 57 | } 58 | } 59 | 60 | init(methods: { 61 | [K in AllInjectMessagesType['method']]: (params: Extract['params'], context: MethodContext) => Promise 62 | }) { 63 | this.methods = methods 64 | chrome.runtime.onMessage.addListener((req, sender, sendResponse) => { 65 | // check target 66 | if (req.target!== 'inject') { 67 | return false 68 | } 69 | 70 | this.messageHandler(req).then(res => { 71 | sendResponse(res) 72 | }) 73 | return true 74 | }) 75 | } 76 | 77 | sendExtension = async (method: K, params?: Extract['params']): Promise['return']> => { 78 | return await chrome.runtime.sendMessage({ 79 | from: 'inject', 80 | target: 'extension', 81 | method, 82 | params: params ?? {}, 83 | }).then(handleRes) 84 | } 85 | 86 | sendApp = async (usePort: boolean | null, method: K, params?: Extract['params']): Promise['return']> => { 87 | if (usePort === null) { 88 | usePort = this.defaultUsePort 89 | } 90 | return await this.sendExtension('_ROUTE' as any, { 91 | usePort, 92 | tag: TAG_TARGET_APP, 93 | method, 94 | params, 95 | }) 96 | } 97 | } 98 | 99 | export default InjectMessaging 100 | -------------------------------------------------------------------------------- /src/utils/req.ts: -------------------------------------------------------------------------------- 1 | import qs from 'qs' 2 | 3 | export default class Req { 4 | baseUrl: string 5 | hasMap: boolean // 是否有map层级 6 | errorHandler?: (error: Error) => void 7 | 8 | constructor(options: { 9 | baseUrl: string 10 | enableDefaultHandler?: boolean 11 | hasMap?: boolean 12 | errorHandler?: (error: Error) => void 13 | }) { 14 | this.baseUrl = options.baseUrl 15 | this.hasMap = options.hasMap??false 16 | this.errorHandler = options.errorHandler 17 | } 18 | 19 | async req(url: string, options?: { [key: string]: any }) { 20 | const {headers = {}, ...restOptions} = options ?? {} 21 | // 选项里的data转body 22 | if (restOptions.data) { 23 | restOptions.body = restOptions.data 24 | delete restOptions.data 25 | } 26 | 27 | return await fetch(this.baseUrl+url, { 28 | headers, 29 | ...restOptions, 30 | }).then(async (resp) => { 31 | if (resp.ok) { 32 | // 处理返回数据 33 | const data = await resp.json() 34 | if (!data.success) { 35 | const error = new Error(data.message) 36 | // @ts-expect-error 37 | error._respData = data 38 | if (this.errorHandler) { 39 | this.errorHandler(error) 40 | } 41 | throw error 42 | } else { 43 | if (this.hasMap) { 44 | return data.map?.data as T 45 | } else { 46 | return data.data as T 47 | } 48 | } 49 | } else { 50 | if (resp.status === 401) { 51 | const error1 = new Error('未登录') 52 | if (this.errorHandler) { 53 | this.errorHandler(error1) 54 | } 55 | throw error1 56 | } else if (resp.status === 403) { 57 | const error1 = new Error('没有权限') 58 | if (this.errorHandler) { 59 | this.errorHandler(error1) 60 | } 61 | throw error1 62 | } else { 63 | const error2 = new Error(`异常(状态码: ${resp.status})`) 64 | if (this.errorHandler) { 65 | this.errorHandler(error2) 66 | } 67 | throw error2 68 | } 69 | } 70 | }) 71 | } 72 | 73 | async get(url: string, options?: { [key: string]: any }) { 74 | return await this.req(url, { 75 | method: 'GET', 76 | ...(options ?? {}), 77 | }) 78 | } 79 | 80 | async post(url: string, options?: { [key: string]: any }) { 81 | return await this.req(url, { 82 | method: 'POST', 83 | ...options, 84 | }) 85 | } 86 | 87 | async postForm(url: string, data: object, options?: { [key: string]: any }) { 88 | return await this.req(url, { 89 | ...(options??{}), 90 | method: 'POST', 91 | headers: { 92 | 'Content-Type': 'application/x-www-form-urlencoded', 93 | ...(options?.headers??{}), 94 | }, 95 | data: qs.stringify(data), 96 | }) 97 | } 98 | 99 | async postJson(url: string, data: object, options?: { [key: string]: any }) { 100 | return await this.req(url, { 101 | ...(options??{}), 102 | method: 'POST', 103 | headers: { 104 | 'Content-Type': 'application/json', 105 | ...(options?.headers??{}), 106 | }, 107 | data: JSON.stringify(data), 108 | }) 109 | } 110 | 111 | async put(url: string, options?: { [key: string]: any }) { 112 | return await this.req(url, { 113 | method: 'PUT', 114 | ...(options ?? {}), 115 | }) 116 | } 117 | 118 | async patch(url: string, options?: { [key: string]: any }) { 119 | return await this.req(url, { 120 | method: 'PATCH', 121 | ...(options ?? {}), 122 | }) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | interface MethodContext { 2 | from: 'extension' | 'inject' | 'app' 3 | event: any 4 | tabId?: number 5 | // sender?: chrome.runtime.MessageSender | null 6 | } 7 | 8 | interface EnvData { 9 | sidePanel?: boolean 10 | manualInsert?: boolean // 是否手动插入字幕列表 11 | autoExpand?: boolean 12 | flagDot?: boolean 13 | 14 | // openai 15 | apiKey?: string 16 | serverUrl?: string 17 | model?: string 18 | customModel?: string 19 | customModelTokens?: number 20 | 21 | translateEnable?: boolean 22 | language?: string 23 | hideOnDisableAutoTranslate?: boolean 24 | transDisplay?: 'target' | 'originPrimary' | 'targetPrimary' 25 | fetchAmount?: number 26 | summarizeEnable?: boolean 27 | summarizeLanguage?: string 28 | words?: number 29 | summarizeFloat?: boolean 30 | theme?: 'system' | 'light' | 'dark' 31 | fontSize?: 'normal' | 'large' 32 | 33 | // chapter 34 | chapterMode?: boolean // 是否启用章节模式,undefined/null/true表示启用,false表示禁用 35 | 36 | // search 37 | searchEnabled?: boolean 38 | cnSearchEnabled?: boolean 39 | 40 | // ask 41 | askEnabled?: boolean 42 | 43 | prompts?: { 44 | [key: string]: string 45 | } 46 | } 47 | 48 | interface TempData { 49 | curSummaryType: SummaryType 50 | downloadType?: string 51 | compact?: boolean // 是否紧凑视图 52 | reviewActions?: number // 点击或总结行为达到一定次数后,显示评分(一个视频最多只加1次) 53 | reviewed?: boolean // 是否点击过评分,undefined: 不显示;true: 已点击;false: 未点击(需要显示) 54 | } 55 | 56 | interface TaskDef { 57 | type: 'chatComplete' 58 | serverUrl?: string 59 | data: any 60 | extra?: any 61 | } 62 | 63 | interface Task { 64 | id: string 65 | startTime: number 66 | endTime?: number 67 | def: TaskDef 68 | 69 | status: 'pending' | 'running' | 'done' 70 | error?: string 71 | resp?: any 72 | } 73 | 74 | interface TransResult { 75 | // idx: number 76 | code?: '200' | '500' 77 | data?: string 78 | } 79 | 80 | type ShowElement = string | JSX.Element | undefined 81 | 82 | interface Transcript { 83 | body: TranscriptItem[] 84 | } 85 | 86 | interface TranscriptItem { 87 | from: number 88 | to: number 89 | content: string 90 | 91 | idx: number 92 | } 93 | 94 | interface Chapter { 95 | from: number 96 | to: number 97 | content: string // 标题 98 | } 99 | 100 | interface Segment { 101 | items: TranscriptItem[] 102 | startIdx: number // 从1开始 103 | endIdx: number 104 | text: string 105 | fold?: boolean 106 | chapterTitle?: string // 章节标题 107 | summaries: { 108 | [type: string]: Summary 109 | } 110 | } 111 | 112 | interface OverviewItem { 113 | time: string 114 | emoji: string 115 | key: string 116 | } 117 | 118 | interface Summary { 119 | type: SummaryType 120 | 121 | status: SummaryStatus 122 | error?: string 123 | content?: any 124 | } 125 | 126 | interface AskInfo { 127 | id: string 128 | fold?: boolean 129 | question: string 130 | status: SummaryStatus 131 | error?: string 132 | content?: string 133 | } 134 | 135 | type PartialOfAskInfo = Partial 136 | 137 | /** 138 | * 概览 139 | */ 140 | interface OverviewSummary extends Summary { 141 | content?: OverviewItem[] 142 | } 143 | 144 | /** 145 | * 要点 146 | */ 147 | interface KeypointSummary extends Summary { 148 | content?: string[] 149 | } 150 | 151 | /** 152 | * 总结 153 | */ 154 | interface BriefSummary extends Summary { 155 | content?: { 156 | summary: string 157 | } 158 | } 159 | 160 | type SummaryStatus = 'init' | 'pending' | 'done' 161 | type SummaryType = 'overview' | 'keypoint' | 'brief' | 'question' | 'debate' 162 | 163 | interface DebateMessage { 164 | side: 'pro' | 'con' 165 | content: string 166 | } 167 | 168 | interface DebateProps { 169 | messages: DebateMessage[] 170 | } 171 | -------------------------------------------------------------------------------- /src/chrome/background.ts: -------------------------------------------------------------------------------- 1 | import {v4} from 'uuid' 2 | import {handleTask, initTaskService, tasksMap} from './taskService' 3 | import { DEFAULT_USE_PORT, STORAGE_ENV} from '@/consts/const' 4 | import { AllExtensionMessages } from '@/message-typings' 5 | import { ExtensionMessaging, TAG_TARGET_INJECT } from '../message' 6 | 7 | const setBadgeOk = async (tabId: number, ok: boolean) => { 8 | await chrome.action.setBadgeText({ 9 | text: ok ? '✓' : '', 10 | tabId, 11 | }) 12 | await chrome.action.setBadgeBackgroundColor({ 13 | color: '#3245e8', 14 | tabId, 15 | }) 16 | await chrome.action.setBadgeTextColor({ 17 | color: '#ffffff', 18 | tabId, 19 | }) 20 | } 21 | 22 | const closeSidePanel = async () => { 23 | chrome.sidePanel.setOptions({ 24 | enabled: false, 25 | }) 26 | chrome.sidePanel.setPanelBehavior({ 27 | openPanelOnActionClick: false, 28 | }) 29 | } 30 | 31 | const methods: { 32 | [K in AllExtensionMessages['method']]: (params: Extract['params'], context: MethodContext) => Promise 33 | } = { 34 | CLOSE_SIDE_PANEL: async (params, context) => { 35 | closeSidePanel() 36 | }, 37 | GET_TAB_ID: async (params, context) => { 38 | return context.tabId 39 | }, 40 | ADD_TASK: async (params, context) => { 41 | // 新建任务 42 | const task: Task = { 43 | id: v4(), 44 | startTime: Date.now(), 45 | status: 'pending', 46 | def: params.taskDef, 47 | } 48 | tasksMap.set(task.id, task) 49 | 50 | // 立即触发任务 51 | handleTask(task).catch(console.error) 52 | 53 | return task 54 | }, 55 | GET_TASK: async (params, context) => { 56 | // 返回任务信息 57 | const taskId = params.taskId 58 | const task = tasksMap.get(taskId) 59 | if (task == null) { 60 | return { 61 | code: 'not_found', 62 | } 63 | } 64 | 65 | // 检测删除缓存 66 | if (task.status === 'done') { 67 | tasksMap.delete(taskId) 68 | } 69 | 70 | // 返回任务 71 | return { 72 | code: 'ok', 73 | task, 74 | } 75 | }, 76 | SHOW_FLAG: async (params, context) => { 77 | await setBadgeOk(context.tabId!, params.show) 78 | }, 79 | } 80 | // 初始化backgroundMessage 81 | const extensionMessaging = new ExtensionMessaging(DEFAULT_USE_PORT) 82 | extensionMessaging.init(methods) 83 | 84 | chrome.runtime.onMessage.addListener((event: any, sender: chrome.runtime.MessageSender, sendResponse: (result: any) => void) => { 85 | // debug((sender.tab != null) ? `tab ${sender.tab.url ?? ''} => ` : 'extension => ', event) 86 | 87 | // legacy 88 | if (event.type === 'syncGet') { // sync.get 89 | chrome.storage.sync.get(event.keys, data => { 90 | sendResponse(data) 91 | }) 92 | return true 93 | } else if (event.type === 'syncSet') { // sync.set 94 | chrome.storage.sync.set(event.items).catch(console.error) 95 | } else if (event.type === 'syncRemove') { // sync.remove 96 | chrome.storage.sync.remove(event.keys).catch(console.error) 97 | } 98 | }) 99 | 100 | // 点击扩展图标 101 | chrome.action.onClicked.addListener(async (tab) => { 102 | chrome.storage.sync.get(STORAGE_ENV, (envDatas) => { 103 | const envDataStr = envDatas[STORAGE_ENV] 104 | const envData = envDataStr ? JSON.parse(envDataStr) : {} 105 | if (envData.sidePanel) { 106 | chrome.sidePanel.setOptions({ 107 | enabled: true, 108 | tabId: tab.id!, 109 | path: '/sidepanel.html?tabId=' + tab.id, 110 | }) 111 | chrome.sidePanel.setPanelBehavior({ 112 | openPanelOnActionClick: true, 113 | }) 114 | chrome.sidePanel.open({ 115 | tabId: tab.id!, 116 | }) 117 | } else { 118 | closeSidePanel() 119 | extensionMessaging.sendMessage(false, tab.id!, TAG_TARGET_INJECT, 'TOGGLE_DISPLAY').catch(console.error) 120 | } 121 | }) 122 | }) 123 | 124 | initTaskService() 125 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import {IoIosArrowUp} from 'react-icons/all' 2 | import {useCallback} from 'react' 3 | import {useAppDispatch, useAppSelector} from '../hooks/redux' 4 | import {find, remove} from 'lodash-es' 5 | import {setCurFetched, setCurInfo, setData, setInfos, setUploadedTranscript} from '../redux/envReducer' 6 | import MoreBtn from './MoreBtn' 7 | import classNames from 'classnames' 8 | import {parseTranscript} from '../utils/bizUtil' 9 | 10 | const Header = (props: { 11 | foldCallback: () => void 12 | }) => { 13 | const {foldCallback} = props 14 | const dispatch = useAppDispatch() 15 | const infos = useAppSelector(state => state.env.infos) 16 | const curInfo = useAppSelector(state => state.env.curInfo) 17 | const fold = useAppSelector(state => state.env.fold) 18 | const uploadedTranscript = useAppSelector(state => state.env.uploadedTranscript) 19 | const envData = useAppSelector(state => state.env.envData) 20 | 21 | const upload = useCallback(() => { 22 | const input = document.createElement('input') 23 | input.type = 'file' 24 | input.accept = '.vtt,.srt' 25 | input.onchange = (e: any) => { 26 | const file = e.target.files[0] 27 | const reader = new FileReader() 28 | reader.onload = (e) => { 29 | const text = e.target?.result 30 | if (text) { 31 | const infos_ = [...(infos??[])] 32 | // const blob = new Blob([text], {type: 'text/plain'}) 33 | // const url = URL.createObjectURL(blob) 34 | // remove old if exist 35 | remove(infos_, {id: 'uploaded'}) 36 | // add new 37 | const tarInfo = {id: 'uploaded', subtitle_url: 'uploaded', lan_doc: '上传的字幕'} 38 | infos_.push(tarInfo) 39 | // set 40 | const transcript = parseTranscript(file.name, text) 41 | dispatch(setInfos(infos_)) 42 | dispatch(setCurInfo(tarInfo)) 43 | dispatch(setCurFetched(true)) 44 | dispatch(setUploadedTranscript(transcript)) 45 | dispatch(setData(transcript)) 46 | } 47 | } 48 | reader.readAsText(file) 49 | } 50 | input.click() 51 | }, [dispatch, infos]) 52 | 53 | const selectCallback = useCallback((e: any) => { 54 | if (e.target.value === 'upload') { 55 | upload() 56 | return 57 | } 58 | 59 | const tarInfo = find(infos, {subtitle_url: e.target.value}) 60 | if (curInfo?.id !== tarInfo?.id) { 61 | dispatch(setCurInfo(tarInfo)) 62 | if (tarInfo && tarInfo.subtitle_url === 'uploaded') { 63 | dispatch(setCurFetched(true)) 64 | dispatch(setData(uploadedTranscript)) 65 | } else { 66 | dispatch(setCurFetched(false)) 67 | } 68 | } 69 | }, [curInfo?.id, dispatch, infos, upload, uploadedTranscript]) 70 | 71 | const preventCallback = useCallback((e: any) => { 72 | e.stopPropagation() 73 | }, []) 74 | 75 | const onUpload = useCallback((e: any) => { 76 | e.stopPropagation() 77 | upload() 78 | }, [upload]) 79 | 80 | return
{ 81 | if (!envData.sidePanel) { 82 | foldCallback() 83 | } 84 | }}> 85 |
86 | {/* Logo */} 87 | 字幕列表 88 | 89 |
90 |
91 | {(infos == null) || infos.length <= 0 92 | ?
93 | 94 | (未找到字幕) 95 |
96 | :} 100 | {!envData.sidePanel && } 101 |
102 |
103 | } 104 | 105 | export default Header 106 | -------------------------------------------------------------------------------- /src/message/layer1/Layer1Protocol.ts: -------------------------------------------------------------------------------- 1 | // 请求信息 2 | interface ReqMsg { 3 | id: string 4 | // 类型 5 | type: 'req' | 'res' 6 | // 请求 7 | req?: L1Req 8 | // 响应 9 | res?: RespMsg 10 | } 11 | 12 | // 响应信息 13 | interface RespMsg { 14 | code: number 15 | data?: T 16 | msg?: string 17 | } 18 | 19 | // 处理函数 20 | type Handler = (req: L1Req) => Promise 21 | 22 | // 创建一个 Layer1Protocol 类,用于持久监听 port 并通过消息 ID 处理响应,支持超时 23 | class Layer1Protocol { 24 | // private name: string 25 | private readonly autoDispose: boolean 26 | private port: chrome.runtime.Port 27 | private readonly timeout: number 28 | private requests: Map void, reject: (reason?: any) => void, timer: number }> 29 | private readonly handler: Handler 30 | public disconnected: boolean = false 31 | 32 | constructor(handler: Handler, port?: chrome.runtime.Port, autoDispose = true, timeout = 30000) { // 默认超时 30 秒 33 | // this.name = name; 34 | this.autoDispose = autoDispose 35 | if (port) { 36 | this.port = port 37 | } else { 38 | this.port = chrome.runtime.connect() 39 | } 40 | this.timeout = timeout 41 | this.requests = new Map() 42 | this.handler = handler 43 | 44 | // 开始监听 45 | this.port.onMessage.addListener(this._messageListener) 46 | if (this.autoDispose) { 47 | this.port.onDisconnect.addListener(this.dispose) 48 | } 49 | } 50 | 51 | // 生成唯一 ID(简单示例,可以使用更复杂的生成策略) 52 | private _generateUniqueId() { 53 | return crypto.randomUUID() 54 | } 55 | 56 | private readonly _messageListener = (msg: ReqMsg) => { 57 | const { id, type, req, res } = msg 58 | if (type === 'req') { 59 | this.handler(req!).then(res => { 60 | const response: RespMsg = { 61 | code: 200, 62 | data: res 63 | } 64 | this.port.postMessage({ id, type: 'res', res: response }) 65 | }).catch(error => { // 业务错误 66 | const response: RespMsg = { 67 | code: 500, 68 | msg: error instanceof Error ? error.message : String(error), 69 | } 70 | this.port.postMessage({ id, type: 'res', res: response }) 71 | }) 72 | } else if (type === 'res') { 73 | if (this.requests.has(id)) { 74 | const { resolve, reject, timer } = this.requests.get(id)! 75 | // 清除超时定时器 76 | clearTimeout(timer) 77 | // 移除消息 ID 78 | this.requests.delete(id) 79 | // 通过 ID 找到对应的 Promise 并 resolve 80 | if (res!.code === 200) { 81 | resolve(res!.data!) 82 | } else { // 业务错误 83 | reject(new Error(`${res!.code}: ${res!.msg || 'Unknown error'}`)) 84 | } 85 | } else { 86 | console.error('unknown response message id: ', id) 87 | } 88 | } else { // 语法格式错误 89 | console.error('unknown message type: ', msg) 90 | } 91 | } 92 | 93 | dispose() { 94 | this.disconnected = true 95 | this.port?.onMessage?.removeListener(this._messageListener) 96 | this.port?.onDisconnect?.removeListener(this.dispose) 97 | this.requests?.forEach(({ timer }) => clearTimeout(timer)) 98 | this.requests?.clear() 99 | this.port?.disconnect() 100 | } 101 | 102 | // 重新连接方法 103 | reconnect() { 104 | // 先断开现有连接 105 | this.dispose() 106 | 107 | // 重新初始化连接 108 | this.port = chrome.runtime.connect() 109 | this.disconnected = false 110 | this.requests = new Map() 111 | 112 | // 重新绑定监听器 113 | this.port.onMessage.addListener(this._messageListener) 114 | if (this.autoDispose) { 115 | this.port.onDisconnect.addListener(this.dispose) 116 | } 117 | } 118 | 119 | // 使用 Promise 发送消息并等待响应,支持超时 120 | async sendMessage(req: L1Req): Promise { 121 | const id = this._generateUniqueId() 122 | 123 | return await new Promise((resolve, reject) => { 124 | // 设置一个超时定时器 125 | const timer = setTimeout(() => { 126 | // 超时后执行 reject 并从 Map 中删除 127 | this.requests.delete(id) 128 | reject(new Error(`Request timed out after ${this.timeout / 1000} seconds`)) 129 | }, this.timeout) 130 | 131 | // 将 resolve 和 timer 函数与消息 ID 绑定,存入 Map 132 | this.requests.set(id, { resolve, reject, timer }) 133 | 134 | try { 135 | this.port.postMessage({ id, type: 'req', req }) 136 | } catch (error) { 137 | clearTimeout(timer) 138 | this.requests.delete(id) 139 | this.dispose() 140 | reject(error) 141 | } 142 | }) 143 | } 144 | } 145 | 146 | export default Layer1Protocol 147 | -------------------------------------------------------------------------------- /src/message/layer2/ExtensionMessaging.ts: -------------------------------------------------------------------------------- 1 | import Layer1Protocol from '../layer1/Layer1Protocol' 2 | import { TAG_TARGET_APP, TAG_TARGET_INJECT } from '../const' 3 | import { ExtensionMessage, MethodContext, InjectMessage, AppMessage, MessagingExtensionMessages, L2ReqMsg, L2ResMsg } from '../typings' 4 | import { handleRes } from '../util' 5 | 6 | interface PortContext { 7 | id: string 8 | // name: string //暂时没什么用 9 | port: chrome.runtime.Port 10 | l1protocol: Layer1Protocol 11 | ready: boolean 12 | 13 | tabId?: number // 所属tab 14 | tag?: string // 标签,用来筛选消息发送目标 15 | } 16 | 17 | type L2MethodHandler = (params: Extract['params'], context: MethodContext, portContext?: PortContext) => Promise 18 | type L2MethodHandlers = { 19 | [K in M['method']]: L2MethodHandler 20 | } 21 | 22 | class ExtensionMessaging { 23 | defaultUsePort: boolean 24 | portIdToPort: Map> = new Map() 25 | methods?: L2MethodHandlers 26 | 27 | debug = (...args: any[]) => { 28 | console.debug('[Extension Messaging]', ...args) 29 | } 30 | 31 | constructor(defaultUsePort: boolean) { 32 | this.defaultUsePort = defaultUsePort 33 | } 34 | 35 | init = (methods: L2MethodHandlers) => { 36 | const innerMethods: L2MethodHandlers = { 37 | _HANDSHAKE: async (params, context: MethodContext, portContext?: PortContext) => { 38 | const tag = params.tag 39 | let tabId = params.tabId 40 | 41 | // get current tabId 42 | if (tabId == null) { 43 | const tabs = await chrome.tabs.query({ 44 | active: true, 45 | currentWindow: true, 46 | }) 47 | tabId = tabs[0]?.id 48 | } 49 | 50 | // 先清理相同tabId与tag的port 51 | for (const portContext_ of this.portIdToPort.values()) { 52 | if (portContext_.tabId === tabId && portContext_.tag === tag && portContext_.id !== portContext!.id) { 53 | this.portIdToPort.delete(portContext_.id) 54 | portContext_.l1protocol.dispose() 55 | this.debug('clean port: ', portContext_.id) 56 | } 57 | } 58 | 59 | portContext!.tabId = tabId 60 | portContext!.tag = tag 61 | portContext!.ready = true 62 | 63 | console.debug('handshake:', portContext!.id, tabId, tag) 64 | }, 65 | _ROUTE: async (params, context: MethodContext) => { 66 | return await this.sendMessage(params.usePort, context.tabId!, params.tag, params.method as any, params.params) 67 | }, 68 | } 69 | 70 | this.methods = { ...innerMethods, ...methods } 71 | 72 | chrome.runtime.onMessage.addListener((req: L2ReqMsg, sender: chrome.runtime.MessageSender, sendResponse) => { 73 | this.debug('onMessage', req, sender) 74 | 75 | // check target 76 | if (req.target !== 'extension') { 77 | return false 78 | } 79 | 80 | const tabId = sender.tab?.id 81 | const method = this.methods?.[req.method as keyof typeof this.methods] 82 | // console.log('msg>>>', tabId, req, method != null) 83 | if (method != null) { 84 | method(req.params, { 85 | from: req.from, 86 | event: req, 87 | tabId, 88 | }).then((data) => { 89 | sendResponse({ 90 | code: 200, 91 | data, 92 | }) 93 | }).catch((err) => { 94 | console.error(err) 95 | sendResponse({ 96 | code: 500, 97 | msg: err.message, 98 | }) 99 | }) 100 | } else { 101 | sendResponse({ 102 | code: 501, 103 | msg: 'Unknown method: ' + req.method, 104 | }) 105 | } 106 | return true 107 | }) 108 | 109 | chrome.runtime.onConnect.addListener((port: chrome.runtime.Port) => { 110 | this.debug('onConnect', port) 111 | 112 | const id = crypto.randomUUID() 113 | // const name = port.name 114 | // 创建消息处理器 115 | const l1protocol = new Layer1Protocol(async (req: L2ReqMsg) => { 116 | const { tabId } = portContext 117 | const method = this.methods?.[req.method as keyof typeof this.methods] 118 | console.debug('ext_msg>>>', tabId, req) 119 | if (method != null) { 120 | return await method(req.params, { 121 | from: req.from, 122 | event: req, 123 | tabId, 124 | // sender: portContext.port.sender, 125 | }, portContext).then((data) => ({ 126 | code: 200, 127 | data, 128 | })).catch((err) => { 129 | console.error(err) 130 | return { 131 | code: 500, 132 | msg: err.message, 133 | } 134 | }) 135 | } else { 136 | return { 137 | code: 501, 138 | msg: 'Unknown method: ' + req.method, 139 | } 140 | } 141 | }, port) 142 | // 创建portContext 143 | const portContext: PortContext = { id, port, l1protocol, ready: false } 144 | this.portIdToPort.set(id, portContext) 145 | 146 | // 监听断开连接 147 | port.onDisconnect.addListener(() => { 148 | this.debug('onDisconnect', id) 149 | this.portIdToPort.delete(id) 150 | }) 151 | }) 152 | } 153 | 154 | sendMessage = async (usePort: boolean | null, tabId: number, tag: string, method: K, params?: Extract['params']): Promise['return']> => { 155 | this.debug('sendMessage', usePort, tabId, tag, method, params) 156 | if (tag === TAG_TARGET_INJECT) { 157 | const res = await chrome.tabs.sendMessage(tabId, { 158 | from: 'extension', 159 | target: 'inject', 160 | method, 161 | params, 162 | }) 163 | return handleRes(res) 164 | } else if (tag === TAG_TARGET_APP) { 165 | if (usePort === null) { 166 | usePort = this.defaultUsePort 167 | } 168 | if (usePort) { 169 | // 这里一定要返回最后一个,而不是第一个就返回,因为开发调试时,应用会启动两次,所以会有两个port,所以这里要返回最后一个 170 | let res: any = null 171 | for (const portContext of this.portIdToPort.values()) { 172 | // check tabId 173 | if (tabId === portContext.tabId!) { 174 | // check tag 175 | if (portContext.tag === tag) { 176 | try { 177 | res = await portContext.l1protocol.sendMessage({ method, params, from: 'extension', target: 'app' }) 178 | } catch (e) { 179 | console.error('send message to port error', portContext.id, e) 180 | } 181 | } 182 | } 183 | } 184 | return res 185 | } else { 186 | const res = await chrome.tabs.sendMessage(tabId, { 187 | from: 'extension', 188 | target: 'app', 189 | method, 190 | params, 191 | }) 192 | return handleRes(res) 193 | } 194 | } else { 195 | throw new Error('Unknown tag:' + tag) 196 | } 197 | } 198 | 199 | broadcastMessageExact = (usePort: boolean | null, tabIds: number[], tag: string, method: K, params?: Extract['params']) => { 200 | for (const tabId of tabIds) { 201 | this.sendMessage(usePort, tabId, tag, method, params) 202 | } 203 | } 204 | 205 | broadcastMessage = async (usePort: boolean | null, ignoreTabIds: number[] | undefined | null, tag: string, method: K, params?: Extract['params']) => { 206 | const tabs = await chrome.tabs.query({ 207 | discarded: false, 208 | }) 209 | const tabIds: number[] = tabs.map(tab => tab.id).filter(tabId => tabId != null) as number[] 210 | const filteredTabIds: number[] = tabIds.filter(tabId => !ignoreTabIds?.includes(tabId)) 211 | this.broadcastMessageExact(usePort, filteredTabIds, tag, method, params) 212 | } 213 | } 214 | 215 | export default ExtensionMessaging 216 | -------------------------------------------------------------------------------- /src/consts/const.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_USE_PORT = false 2 | 3 | export const EVENT_EXPAND = 'expand' 4 | 5 | export const APP_DOM_ID = 'bilibili-subtitle' 6 | 7 | export const IFRAME_ID = 'bilibili-subtitle-iframe' 8 | 9 | export const STORAGE_ENV = 'bilibili-subtitle_env' 10 | export const STORAGE_TEMP = 'bilibili-subtitle_temp' 11 | 12 | export const PROMPT_TYPE_TRANSLATE = 'translate' 13 | export const PROMPT_TYPE_SUMMARIZE_OVERVIEW = 'summarize_overview' 14 | export const PROMPT_TYPE_SUMMARIZE_KEYPOINT = 'summarize_keypoint' 15 | export const PROMPT_TYPE_SUMMARIZE_QUESTION = 'summarize_question' 16 | export const PROMPT_TYPE_SUMMARIZE_DEBATE = 'summarize_debate' 17 | export const PROMPT_TYPE_SUMMARIZE_BRIEF = 'summarize_brief' 18 | export const PROMPT_TYPE_ASK = 'ask' 19 | export const PROMPT_TYPES = [{ 20 | name: '翻译', 21 | type: PROMPT_TYPE_TRANSLATE, 22 | }, { 23 | name: '概览', 24 | type: PROMPT_TYPE_SUMMARIZE_OVERVIEW, 25 | }, { 26 | name: '要点', 27 | type: PROMPT_TYPE_SUMMARIZE_KEYPOINT, 28 | }, { 29 | name: '总结', 30 | type: PROMPT_TYPE_SUMMARIZE_BRIEF, 31 | }, { 32 | name: '问题', 33 | type: PROMPT_TYPE_SUMMARIZE_QUESTION, 34 | }, { 35 | name: '辩论', 36 | type: PROMPT_TYPE_SUMMARIZE_DEBATE, 37 | }, { 38 | name: '提问', 39 | type: PROMPT_TYPE_ASK, 40 | }] 41 | 42 | export const SUMMARIZE_TYPES = { 43 | brief: { 44 | name: '总结', 45 | desc: '一句话总结', 46 | downloadName: '💡视频总结💡', 47 | promptType: PROMPT_TYPE_SUMMARIZE_BRIEF, 48 | }, 49 | overview: { 50 | name: '概览', 51 | desc: '可定位到视频位置', 52 | downloadName: '💡视频概览💡', 53 | promptType: PROMPT_TYPE_SUMMARIZE_OVERVIEW, 54 | }, 55 | keypoint: { 56 | name: '要点', 57 | desc: '完整的要点提取', 58 | downloadName: '💡视频要点💡', 59 | promptType: PROMPT_TYPE_SUMMARIZE_KEYPOINT, 60 | }, 61 | question: { 62 | name: '问题', 63 | desc: '常见问题', 64 | downloadName: '💡常见问题💡', 65 | promptType: PROMPT_TYPE_SUMMARIZE_QUESTION, 66 | }, 67 | debate: { 68 | name: '辩论', 69 | desc: '辩论', 70 | downloadName: '💡辩论💡', 71 | promptType: PROMPT_TYPE_SUMMARIZE_DEBATE, 72 | }, 73 | } 74 | 75 | export const PROMPT_DEFAULTS = { 76 | [PROMPT_TYPE_TRANSLATE]: `You are a professional translator. Translate following video subtitles to language '{{language}}'. 77 | Preserve incomplete sentence. 78 | Translate in the same json format. 79 | Answer in markdown json format. 80 | 81 | video subtitles: 82 | 83 | \`\`\` 84 | {{subtitles}} 85 | \`\`\``, 86 | [PROMPT_TYPE_SUMMARIZE_OVERVIEW]: `You are a helpful assistant that summarize key points of video subtitle. 87 | Summarize 3 to 8 brief key points in language '{{language}}'. 88 | Answer in markdown json format. 89 | The emoji should be related to the key point and 1 char length. 90 | 91 | example output format: 92 | 93 | \`\`\`json 94 | [ 95 | { 96 | "time": "03:00", 97 | "emoji": "👍", 98 | "key": "key point 1" 99 | }, 100 | { 101 | "time": "10:05", 102 | "emoji": "😊", 103 | "key": "key point 2" 104 | } 105 | ] 106 | \`\`\` 107 | 108 | The video's title: '''{{title}}'''. 109 | The video's subtitles: 110 | 111 | ''' 112 | {{subtitles}} 113 | '''`, 114 | [PROMPT_TYPE_SUMMARIZE_KEYPOINT]: `You are a helpful assistant that summarize key points of video subtitle. 115 | Summarize brief key points in language '{{language}}'. 116 | Answer in markdown json format. 117 | 118 | example output format: 119 | 120 | \`\`\`json 121 | [ 122 | "key point 1", 123 | "key point 2" 124 | ] 125 | \`\`\` 126 | 127 | The video's title: '''{{title}}'''. 128 | The video's subtitles: 129 | 130 | ''' 131 | {{segment}} 132 | '''`, 133 | [PROMPT_TYPE_SUMMARIZE_BRIEF]: `You are a helpful assistant that summarize video subtitle. 134 | Summarize in language '{{language}}'. 135 | Answer in markdown json format. 136 | 137 | example output format: 138 | 139 | \`\`\`json 140 | { 141 | "summary": "brief summary" 142 | } 143 | \`\`\` 144 | 145 | The video's title: '''{{title}}'''. 146 | The video's subtitles: 147 | 148 | ''' 149 | {{segment}} 150 | '''`, 151 | [PROMPT_TYPE_SUMMARIZE_QUESTION]: `You are a helpful assistant that skilled at extracting questions from video subtitle. 152 | 153 | ## Context 154 | 155 | The video's title: '''{{title}}'''. 156 | The video's subtitles: 157 | 158 | ''' 159 | {{segment}} 160 | ''' 161 | 162 | ## Command 163 | 164 | Accurately extract key questions and their corresponding answers from the video subtitles based on the actual content provided. The number of questions should be between 3 and 5. 165 | 166 | - Identify questions as sentences starting with interrogative words (e.g., "What", "How", "Why") and extract the following sentences that directly answer these questions. 167 | - Include only those questions and answers that are relevant to the main points of the video, and ensure they cover different aspects of the video's content. 168 | - If an answer spans multiple non-consecutive parts of the subtitles, concatenate them into a coherent response without adding any information not present in the subtitles. 169 | - In cases where the number of potential Q&As exceeds 5, prioritize the most informative and directly answered ones. 170 | - If clear questions and answers are not available in the subtitles, refrain from creating them and instead note the absence of direct Q&As. 171 | - Answer in language '{{language}}'. 172 | - Format the output in markdown json format, as specified. 173 | 174 | ## Output format 175 | 176 | Provide an example to illustrate the expected output: 177 | 178 | \`\`\`json 179 | [ 180 | { 181 | "q": "What is the main theme of the video?", 182 | "a": "The main theme of the video is explained as..." 183 | }, 184 | { 185 | "q": "How is the topic developed?", 186 | "a": "The topic is developed through various examples, including..." 187 | } 188 | ] 189 | \`\`\` 190 | `, 191 | [PROMPT_TYPE_SUMMARIZE_DEBATE]: `You are a helpful assistant skilled at generating debates based on video subtitles. 192 | 193 | ## Context 194 | 195 | The video's title: '''{{title}}'''. 196 | The video's subtitles: 197 | 198 | ''' 199 | {{segment}} 200 | ''' 201 | 202 | ## Command 203 | 204 | Please play the roles of both the affirmative and negative sides to discuss the author's viewpoint. 205 | The conversation should consist of 10 rounds(5 sentences from the affirmative side, 5 sentences from the negative side.). 206 | The tone should be straightforward. 207 | 208 | Answer in language '{{language}}'. 209 | 210 | ## Output format 211 | 212 | Provide an example to illustrate the expected output: 213 | 214 | \`\`\`json 215 | [ 216 | { 217 | "side": "pro", 218 | "content": "xxx" 219 | }, 220 | { 221 | "side": "con", 222 | "content": "xxx" 223 | } 224 | ] 225 | \`\`\` 226 | `, 227 | [PROMPT_TYPE_ASK]: `You are a helpful assistant who answers question related to video subtitles. 228 | Answer in language '{{language}}'. 229 | 230 | The video's title: '''{{title}}'''. 231 | The video's subtitles: 232 | 233 | ''' 234 | {{segment}} 235 | ''' 236 | 237 | Question: '''{{question}}''' 238 | Answer: 239 | `, 240 | } 241 | 242 | export const TASK_EXPIRE_TIME = 15*60*1000 243 | 244 | export const PAGE_MAIN = 'main' 245 | export const PAGE_SETTINGS = 'settings' 246 | 247 | export const TRANSLATE_COOLDOWN = 5*1000 248 | export const TRANSLATE_FETCH_DEFAULT = 15 249 | export const TRANSLATE_FETCH_MIN = 5 250 | export const TRANSLATE_FETCH_MAX = 25 251 | export const TRANSLATE_FETCH_STEP = 5 252 | export const LANGUAGE_DEFAULT = 'en' 253 | 254 | export const TOTAL_HEIGHT_MIN = 400 255 | export const TOTAL_HEIGHT_DEF = 520 256 | export const TOTAL_HEIGHT_MAX = 800 257 | export const HEADER_HEIGHT = 44 258 | export const TITLE_HEIGHT = 24 259 | export const SEARCH_BAR_HEIGHT = 32 260 | export const RECOMMEND_HEIGHT = 36 261 | 262 | export const WORDS_RATE = 0.75 263 | export const WORDS_MIN = 500 264 | export const WORDS_MAX = 16000 265 | export const WORDS_STEP = 500 266 | export const SUMMARIZE_THRESHOLD = 100 267 | export const SUMMARIZE_LANGUAGE_DEFAULT = 'cn' 268 | export const SUMMARIZE_ALL_THRESHOLD = 5 269 | export const ASK_ENABLED_DEFAULT = true 270 | export const DEFAULT_SERVER_URL_OPENAI = 'https://api.openai.com' 271 | export const DEFAULT_SERVER_URL_GEMINI = 'https://generativelanguage.googleapis.com/v1beta/openai/' 272 | export const CUSTOM_MODEL_TOKENS = 16385 273 | 274 | export const MODEL_TIP = '推荐gpt-4o-mini,能力强,价格低,token上限大' 275 | export const MODELS = [{ 276 | code: 'gpt-4o-mini', 277 | name: 'gpt-4o-mini', 278 | tokens: 128000, 279 | }, { 280 | code: 'gpt-3.5-turbo-0125', 281 | name: 'gpt-3.5-turbo-0125', 282 | tokens: 16385, 283 | }, { 284 | code: 'custom', 285 | name: '自定义', 286 | }] 287 | export const MODEL_DEFAULT = MODELS[0].code 288 | export const MODEL_MAP: {[key: string]: typeof MODELS[number]} = {} 289 | for (const model of MODELS) { 290 | MODEL_MAP[model.code] = model 291 | } 292 | 293 | export const LANGUAGES = [{ 294 | code: 'en', 295 | name: 'English', 296 | }, { 297 | code: 'ja', 298 | name: '日本語', 299 | }, { 300 | code: 'ena', 301 | name: 'American English', 302 | }, { 303 | code: 'enb', 304 | name: 'British English', 305 | }, { 306 | code: 'cn', 307 | name: '中文简体', 308 | }, { 309 | code: 'cnt', 310 | name: '中文繁体', 311 | }, { 312 | code: 'Spanish', 313 | name: 'español', 314 | }, { 315 | code: 'French', 316 | name: 'Français', 317 | }, { 318 | code: 'Arabic', 319 | name: 'العربية', 320 | }, { 321 | code: 'Russian', 322 | name: 'русский', 323 | }, { 324 | code: 'German', 325 | name: 'Deutsch', 326 | }, { 327 | code: 'Portuguese', 328 | name: 'Português', 329 | }, { 330 | code: 'Italian', 331 | name: 'Italiano', 332 | }, { 333 | code: 'ko', 334 | name: '한국어', 335 | }, { 336 | code: 'hi', 337 | name: 'हिन्दी', 338 | }, { 339 | code: 'tr', 340 | name: 'Türkçe', 341 | }, { 342 | code: 'nl', 343 | name: 'Nederlands', 344 | }, { 345 | code: 'pl', 346 | name: 'Polski', 347 | }, { 348 | code: 'sv', 349 | name: 'Svenska', 350 | }, { 351 | code: 'vi', 352 | name: 'Tiếng Việt', 353 | }, { 354 | code: 'th', 355 | name: 'ไทย', 356 | }, { 357 | code: 'id', 358 | name: 'Bahasa Indonesia', 359 | }, { 360 | code: 'el', 361 | name: 'Ελληνικά', 362 | }, { 363 | code: 'he', 364 | name: 'עברית', 365 | }] 366 | export const LANGUAGES_MAP: {[key: string]: typeof LANGUAGES[number]} = {} 367 | for (const language of LANGUAGES) { 368 | LANGUAGES_MAP[language.code] = language 369 | } 370 | -------------------------------------------------------------------------------- /src/hooks/useSubtitleService.ts: -------------------------------------------------------------------------------- 1 | import {useAppDispatch, useAppSelector} from './redux' 2 | import {useContext, useEffect} from 'react' 3 | import { 4 | setCurFetched, 5 | setCurIdx, 6 | setCurInfo, 7 | setData, 8 | setNoVideo, 9 | setSegmentFold, 10 | setSegments, 11 | setTotalHeight, 12 | setTempData, 13 | } from '../redux/envReducer' 14 | import {EventBusContext} from '../Router' 15 | import {EVENT_EXPAND, TOTAL_HEIGHT_MAX, TOTAL_HEIGHT_MIN, WORDS_MIN, WORDS_RATE} from '../consts/const' 16 | import {useAsyncEffect, useInterval} from 'ahooks' 17 | import {getModelMaxTokens, getWholeText} from '../utils/bizUtil' 18 | import { useMessage } from './useMessageService' 19 | import { setCurrentTime } from '../redux/currentTimeReducer' 20 | import { RootState } from '../store' 21 | 22 | /** 23 | * Service是单例,类似后端的服务概念 24 | */ 25 | const useSubtitleService = () => { 26 | const dispatch = useAppDispatch() 27 | const infos = useAppSelector(state => state.env.infos) 28 | const curInfo = useAppSelector(state => state.env.curInfo) 29 | const curFetched = useAppSelector(state => state.env.curFetched) 30 | const fold = useAppSelector(state => state.env.fold) 31 | const envReady = useAppSelector(state => state.env.envReady) 32 | const envData = useAppSelector((state: RootState) => state.env.envData) 33 | const data = useAppSelector((state: RootState) => state.env.data) 34 | const chapters = useAppSelector((state: RootState) => state.env.chapters) 35 | const currentTime = useAppSelector((state: RootState) => state.currentTime.currentTime) 36 | const curIdx = useAppSelector((state: RootState) => state.env.curIdx) 37 | const eventBus = useContext(EventBusContext) 38 | const needScroll = useAppSelector(state => state.env.needScroll) 39 | const segments = useAppSelector(state => state.env.segments) 40 | const transResults = useAppSelector(state => state.env.transResults) 41 | const hideOnDisableAutoTranslate = useAppSelector(state => state.env.envData.hideOnDisableAutoTranslate) 42 | const autoTranslate = useAppSelector(state => state.env.autoTranslate) 43 | const reviewed = useAppSelector(state => state.env.tempData.reviewed) 44 | const reviewActions = useAppSelector(state => state.env.tempData.reviewActions) 45 | const {sendInject} = useMessage(!!envData.sidePanel) 46 | 47 | // 如果reviewActions达到15次,则设置reviewed为false 48 | useEffect(() => { 49 | if (reviewed === undefined && reviewActions && reviewActions >= 15) { 50 | dispatch(setTempData({ 51 | reviewed: false 52 | })) 53 | } 54 | }, [reviewActions, dispatch, reviewed]) 55 | 56 | // 有数据时自动展开 57 | useEffect(() => { 58 | if ((data != null) && data.body.length > 0) { 59 | eventBus.emit({ 60 | type: EVENT_EXPAND 61 | }) 62 | } 63 | }, [data, eventBus, infos]) 64 | 65 | // 当前未展示 & (未折叠 | 自动展开) & 有列表 => 展示第一个 66 | useEffect(() => { 67 | let autoExpand = envData.autoExpand 68 | // 如果显示在侧边栏,则自动展开 69 | if (envData.sidePanel) { 70 | autoExpand = true 71 | } 72 | if (!curInfo && (!fold || (envReady && autoExpand)) && (infos != null) && infos.length > 0) { 73 | dispatch(setCurInfo(infos[0])) 74 | dispatch(setCurFetched(false)) 75 | } 76 | }, [curInfo, dispatch, envData.autoExpand, envReady, fold, infos, envData.sidePanel]) 77 | // 获取 78 | useEffect(() => { 79 | if (curInfo && !curFetched) { 80 | sendInject(null, 'GET_SUBTITLE', {info: curInfo}).then(data => { 81 | data?.body?.forEach((item: TranscriptItem, idx: number) => { 82 | item.idx = idx 83 | }) 84 | // dispatch(setCurInfo(data.data.info)) 85 | dispatch(setCurFetched(true)) 86 | dispatch(setData(data)) 87 | 88 | console.debug('subtitle', data) 89 | }) 90 | } 91 | }, [curFetched, curInfo, dispatch, sendInject]) 92 | 93 | useAsyncEffect(async () => { 94 | // 初始获取列表 95 | if (envReady) { 96 | sendInject(null, 'REFRESH_VIDEO_INFO', {force: true}) 97 | } 98 | }, [envReady, sendInject]) 99 | 100 | useAsyncEffect(async () => { 101 | // 更新设置信息 102 | sendInject(null, 'GET_VIDEO_ELEMENT_INFO', {}).then(info => { 103 | dispatch(setNoVideo(info.noVideo)) 104 | if (envData.sidePanel) { 105 | // get screen height 106 | dispatch(setTotalHeight(window.innerHeight)) 107 | } else { 108 | dispatch(setTotalHeight(Math.min(Math.max(info.totalHeight, TOTAL_HEIGHT_MIN), TOTAL_HEIGHT_MAX))) 109 | } 110 | }) 111 | }, [envData.sidePanel, infos, sendInject]) 112 | 113 | // 更新当前位置 114 | useEffect(() => { 115 | let newCurIdx 116 | if (((data?.body) != null) && currentTime) { 117 | for (let i=0; i segment自动展开 133 | useEffect(() => { 134 | if (needScroll && curIdx != null) { // 需要滚动 135 | for (const segment of segments??[]) { // 检测segments 136 | if (segment.startIdx <= curIdx && curIdx <= segment.endIdx) { // 找到对应的segment 137 | if (segment.fold) { // 需要展开 138 | dispatch(setSegmentFold({ 139 | segmentStartIdx: segment.startIdx, 140 | fold: false 141 | })) 142 | } 143 | break 144 | } 145 | } 146 | } 147 | }, [curIdx, dispatch, needScroll, segments]) 148 | 149 | // data等变化时自动刷新segments 150 | useEffect(() => { 151 | let segments: Segment[] | undefined 152 | const items = data?.body 153 | if (items != null) { 154 | if (envData.summarizeEnable) { // 分段 155 | let size = envData.words 156 | if (!size) { // 默认 157 | size = getModelMaxTokens(envData)*WORDS_RATE 158 | } 159 | size = Math.max(size, WORDS_MIN) 160 | 161 | segments = [] 162 | 163 | // 如果启用章节模式且有章节信息,按章节分割 164 | if ((envData.chapterMode ?? true) && chapters && chapters.length > 0) { 165 | for (let chapterIdx = 0; chapterIdx < chapters.length; chapterIdx++) { 166 | const chapter = chapters[chapterIdx] 167 | const nextChapter = chapters[chapterIdx + 1] 168 | 169 | // 找到属于当前章节的字幕项 170 | const chapterItems = items.filter(item => { 171 | const itemTime = item.from 172 | return itemTime >= chapter.from && (nextChapter ? itemTime < nextChapter.from : true) 173 | }) 174 | 175 | if (chapterItems.length === 0) continue 176 | 177 | // 如果章节内容过长,需要进一步分割 178 | const chapterText = getWholeText(chapterItems.map(item => item.content)) 179 | if (chapterText.length <= size) { 180 | // 章节内容不长,作为一个segment 181 | segments.push({ 182 | items: chapterItems, 183 | startIdx: chapterItems[0].idx, 184 | endIdx: chapterItems[chapterItems.length - 1].idx, 185 | text: chapterText, 186 | chapterTitle: chapter.content, 187 | summaries: {}, 188 | }) 189 | } else { 190 | // 章节内容过长,需要分割成多个segment 191 | let transcriptItems: TranscriptItem[] = [] 192 | let totalLength = 0 193 | for (let i = 0; i < chapterItems.length; i++) { 194 | const item = chapterItems[i] 195 | transcriptItems.push(item) 196 | totalLength += item.content.length 197 | if (totalLength >= size || i === chapterItems.length - 1) { 198 | segments.push({ 199 | items: transcriptItems, 200 | startIdx: transcriptItems[0].idx, 201 | endIdx: transcriptItems[transcriptItems.length - 1].idx, 202 | text: getWholeText(transcriptItems.map(item => item.content)), 203 | chapterTitle: chapter.content, 204 | summaries: {}, 205 | }) 206 | // reset 207 | transcriptItems = [] 208 | totalLength = 0 209 | } 210 | } 211 | } 212 | } 213 | } else { 214 | // 没有章节信息,按原来的逻辑分割 215 | let transcriptItems: TranscriptItem[] = [] 216 | let totalLength = 0 217 | for (let i = 0; i < items.length; i++) { 218 | const item = items[i] 219 | transcriptItems.push(item) 220 | totalLength += item.content.length 221 | if (totalLength >= size || i === items.length-1) { // new segment or last 222 | // add 223 | segments.push({ 224 | items: transcriptItems, 225 | startIdx: transcriptItems[0].idx, 226 | endIdx: transcriptItems[transcriptItems.length - 1].idx, 227 | text: getWholeText(transcriptItems.map(item => item.content)), 228 | summaries: {}, 229 | }) 230 | // reset 231 | transcriptItems = [] 232 | totalLength = 0 233 | } 234 | } 235 | } 236 | } else { // 都放一个分段 237 | segments = [{ 238 | items, 239 | startIdx: 0, 240 | endIdx: items.length-1, 241 | text: getWholeText(items.map(item => item.content)), 242 | summaries: {}, 243 | }] 244 | } 245 | } 246 | dispatch(setSegments(segments)) 247 | }, [data?.body, dispatch, envData, chapters]) 248 | 249 | // 每0.5秒更新当前视频时间 250 | useInterval(() => { 251 | sendInject(null, 'GET_VIDEO_STATUS', {}).then(status => { 252 | // 只有当时间发生显著变化时才更新状态(差异大于0.1秒),避免不必要的重新渲染 253 | if (currentTime == null || Math.abs(status.currentTime - currentTime) > 0.1) { 254 | dispatch(setCurrentTime(status.currentTime)) 255 | } 256 | }) 257 | }, 500) 258 | 259 | // show translated text in the video 260 | useEffect(() => { 261 | if (hideOnDisableAutoTranslate && !autoTranslate) { 262 | sendInject(null, 'HIDE_TRANS', {}) 263 | return 264 | } 265 | 266 | const transResult = curIdx?transResults[curIdx]:undefined 267 | if (transResult?.code === '200' && transResult.data) { 268 | sendInject(null, 'UPDATE_TRANS_RESULT', {result: transResult.data}) 269 | } else { 270 | sendInject(null, 'HIDE_TRANS', {}) 271 | } 272 | }, [autoTranslate, curIdx, hideOnDisableAutoTranslate, sendInject, transResults]) 273 | } 274 | 275 | export default useSubtitleService 276 | -------------------------------------------------------------------------------- /src/redux/envReducer.ts: -------------------------------------------------------------------------------- 1 | import {createSlice, PayloadAction} from '@reduxjs/toolkit' 2 | import {find, findIndex} from 'lodash-es' 3 | import {DEFAULT_SERVER_URL_OPENAI, TOTAL_HEIGHT_DEF} from '../consts/const' 4 | 5 | interface EnvState { 6 | envData: EnvData 7 | envReady: boolean 8 | 9 | tempData: TempData 10 | tempReady: boolean 11 | 12 | path?: 'app' | 'options' 13 | 14 | fold: boolean // fold app 15 | foldAll?: boolean // fold all segments 16 | autoTranslate?: boolean 17 | autoScroll?: boolean 18 | checkAutoScroll?: boolean 19 | curOffsetTop?: number 20 | floatKeyPointsSegIdx?: number // segment的startIdx 21 | 22 | noVideo?: boolean 23 | totalHeight: number 24 | curIdx?: number // 从0开始 25 | needScroll?: boolean 26 | chapters?: Chapter[] 27 | infos?: any[] 28 | curInfo?: any 29 | curFetched?: boolean 30 | data?: Transcript 31 | uploadedTranscript?: Transcript 32 | segments?: Segment[] 33 | url?: string 34 | title?: string 35 | ctime?: number | null 36 | author?: string 37 | taskIds?: string[] 38 | transResults: { [key: number]: TransResult } 39 | lastTransTime?: number 40 | lastSummarizeTime?: number 41 | 42 | // ask 43 | asks: AskInfo[] 44 | 45 | /** 46 | * 是否输入中(中文) 47 | */ 48 | inputting: boolean 49 | 50 | searchText: string 51 | searchResult: Record 52 | 53 | // 当前视频是否计算过操作 54 | reviewAction: boolean 55 | } 56 | 57 | const initialState: EnvState = { 58 | envData: { 59 | serverUrl: DEFAULT_SERVER_URL_OPENAI, 60 | translateEnable: true, 61 | summarizeEnable: true, 62 | autoExpand: true, 63 | theme: 'light', 64 | searchEnabled: true, 65 | }, 66 | tempData: { 67 | curSummaryType: 'overview', 68 | }, 69 | totalHeight: TOTAL_HEIGHT_DEF, 70 | autoScroll: true, 71 | envReady: false, 72 | tempReady: false, 73 | fold: true, 74 | transResults: {}, 75 | 76 | inputting: false, 77 | 78 | searchText: '', 79 | searchResult: {}, 80 | 81 | asks: [], 82 | 83 | reviewAction: false, 84 | } 85 | 86 | export const slice = createSlice({ 87 | name: 'env', 88 | initialState, 89 | reducers: { 90 | setEnvData: (state, action: PayloadAction) => { 91 | state.envData = { 92 | ...state.envData, 93 | ...action.payload, 94 | } 95 | }, 96 | setEnvReady: (state) => { 97 | state.envReady = true 98 | }, 99 | setTempData: (state, action: PayloadAction>) => { 100 | state.tempData = { 101 | ...state.tempData, 102 | ...action.payload, 103 | } 104 | }, 105 | setReviewAction: (state, action: PayloadAction) => { 106 | state.reviewAction = action.payload 107 | }, 108 | setPath: (state, action: PayloadAction<'app' | 'options' | undefined>) => { 109 | state.path = action.payload 110 | }, 111 | setTempReady: (state) => { 112 | state.tempReady = true 113 | }, 114 | setSearchText: (state, action: PayloadAction) => { 115 | state.searchText = action.payload 116 | }, 117 | setSearchResult: (state, action: PayloadAction>) => { 118 | state.searchResult = action.payload 119 | }, 120 | setFloatKeyPointsSegIdx: (state, action: PayloadAction) => { 121 | state.floatKeyPointsSegIdx = action.payload 122 | }, 123 | setFoldAll: (state, action: PayloadAction) => { 124 | state.foldAll = action.payload 125 | }, 126 | setTotalHeight: (state, action: PayloadAction) => { 127 | state.totalHeight = action.payload 128 | }, 129 | setTaskIds: (state, action: PayloadAction) => { 130 | state.taskIds = action.payload 131 | }, 132 | setLastTransTime: (state, action: PayloadAction) => { 133 | state.lastTransTime = action.payload 134 | }, 135 | setLastSummarizeTime: (state, action: PayloadAction) => { 136 | state.lastSummarizeTime = action.payload 137 | }, 138 | addTaskId: (state, action: PayloadAction) => { 139 | state.taskIds = [...(state.taskIds ?? []), action.payload] 140 | }, 141 | delTaskId: (state, action: PayloadAction) => { 142 | state.taskIds = state.taskIds?.filter(id => id !== action.payload) 143 | }, 144 | addTransResults: (state, action: PayloadAction<{ [key: number]: TransResult }>) => { 145 | // 不要覆盖TransResult里code为200的 146 | for (const payloadKey in action.payload) { 147 | const payloadItem = action.payload[payloadKey] 148 | const stateItem = state.transResults[payloadKey] 149 | if (!stateItem || stateItem.code !== '200') { 150 | state.transResults[payloadKey] = payloadItem 151 | } else if (stateItem.code === '200') { // 保留data 152 | state.transResults[payloadKey] = { 153 | ...payloadItem, 154 | data: stateItem.data, 155 | } 156 | } 157 | } 158 | }, 159 | setSummaryContent: (state, action: PayloadAction<{ 160 | segmentStartIdx: number 161 | type: SummaryType 162 | content?: any 163 | }>) => { 164 | const segment = find(state.segments, {startIdx: action.payload.segmentStartIdx}) 165 | if (segment != null) { 166 | let summary = segment.summaries[action.payload.type] 167 | if (!summary) { 168 | summary = { 169 | type: action.payload.type, 170 | status: 'done', 171 | content: action.payload.content, 172 | } 173 | segment.summaries[action.payload.type] = summary 174 | } else { 175 | summary.content = action.payload.content 176 | } 177 | } 178 | }, 179 | setSummaryStatus: (state, action: PayloadAction<{ 180 | segmentStartIdx: number 181 | type: SummaryType 182 | status: SummaryStatus 183 | }>) => { 184 | const segment = find(state.segments, {startIdx: action.payload.segmentStartIdx}) 185 | if (segment != null) { 186 | let summary = segment.summaries[action.payload.type] 187 | if (summary) { 188 | summary.status = action.payload.status 189 | } else { 190 | summary = { 191 | type: action.payload.type, 192 | status: action.payload.status, 193 | } 194 | segment.summaries[action.payload.type] = summary 195 | } 196 | } 197 | }, 198 | setSummaryError: (state, action: PayloadAction<{ 199 | segmentStartIdx: number 200 | type: SummaryType 201 | error?: string 202 | }>) => { 203 | const segment = find(state.segments, {startIdx: action.payload.segmentStartIdx}) 204 | if (segment != null) { 205 | let summary = segment.summaries[action.payload.type] 206 | if (summary) { 207 | summary.error = action.payload.error 208 | } else { 209 | summary = { 210 | type: action.payload.type, 211 | status: 'done', 212 | error: action.payload.error, 213 | } 214 | segment.summaries[action.payload.type] = summary 215 | } 216 | } 217 | }, 218 | addAskInfo: (state, action: PayloadAction) => { 219 | state.asks.push(action.payload) 220 | }, 221 | delAskInfo: (state, action: PayloadAction) => { 222 | state.asks = state.asks.filter(ask => ask.id !== action.payload) 223 | }, 224 | mergeAskInfo: (state, action: PayloadAction) => { 225 | const idx = findIndex(state.asks, {id: action.payload.id}) 226 | if (idx >= 0) { 227 | state.asks[idx] = { 228 | ...state.asks[idx], 229 | ...action.payload, 230 | } 231 | } 232 | }, 233 | setSegmentFold: (state, action: PayloadAction<{ 234 | segmentStartIdx: number 235 | fold: boolean 236 | }>) => { 237 | const segment = find(state.segments, {startIdx: action.payload.segmentStartIdx}) 238 | if (segment != null) { 239 | segment.fold = action.payload.fold 240 | } 241 | }, 242 | clearTransResults: (state) => { 243 | state.transResults = {} 244 | }, 245 | setCurIdx: (state, action: PayloadAction) => { 246 | state.curIdx = action.payload 247 | }, 248 | setAutoTranslate: (state, action: PayloadAction) => { 249 | state.autoTranslate = action.payload 250 | }, 251 | setAutoScroll: (state, action: PayloadAction) => { 252 | state.autoScroll = action.payload 253 | }, 254 | setCheckAutoScroll: (state, action: PayloadAction) => { 255 | state.checkAutoScroll = action.payload 256 | }, 257 | setCurOffsetTop: (state, action: PayloadAction) => { 258 | state.curOffsetTop = action.payload 259 | }, 260 | setNoVideo: (state, action: PayloadAction) => { 261 | state.noVideo = action.payload 262 | }, 263 | setNeedScroll: (state, action: PayloadAction) => { 264 | state.needScroll = action.payload 265 | }, 266 | setUrl: (state, action: PayloadAction) => { 267 | state.url = action.payload 268 | }, 269 | setTitle: (state, action: PayloadAction) => { 270 | state.title = action.payload 271 | }, 272 | setCtime: (state, action: PayloadAction) => { 273 | state.ctime = action.payload 274 | }, 275 | setAuthor: (state, action: PayloadAction) => { 276 | state.author = action.payload 277 | }, 278 | setChapters: (state, action: PayloadAction) => { 279 | state.chapters = action.payload 280 | }, 281 | setInfos: (state, action: PayloadAction) => { 282 | state.infos = action.payload 283 | }, 284 | setCurInfo: (state, action: PayloadAction) => { 285 | state.curInfo = action.payload 286 | }, 287 | setCurFetched: (state, action: PayloadAction) => { 288 | state.curFetched = action.payload 289 | }, 290 | setData: (state, action: PayloadAction) => { 291 | state.data = action.payload 292 | }, 293 | setUploadedTranscript: (state, action: PayloadAction) => { 294 | state.uploadedTranscript = action.payload 295 | }, 296 | setSegments: (state, action: PayloadAction) => { 297 | state.segments = action.payload 298 | }, 299 | setFold: (state, action: PayloadAction) => { 300 | state.fold = action.payload 301 | }, 302 | setInputting: (state, action: PayloadAction) => { 303 | state.inputting = action.payload 304 | }, 305 | }, 306 | }) 307 | 308 | export const { 309 | setPath, 310 | setUrl, 311 | setTempReady, 312 | setTempData, 313 | setUploadedTranscript, 314 | setTotalHeight, 315 | setCheckAutoScroll, 316 | setCurOffsetTop, 317 | setFloatKeyPointsSegIdx, 318 | setFoldAll, 319 | setSegmentFold, 320 | setSummaryContent, 321 | setSummaryStatus, 322 | setSummaryError, 323 | setTitle, 324 | setSegments, 325 | setLastSummarizeTime, 326 | setLastTransTime, 327 | clearTransResults, 328 | addTransResults, 329 | addTaskId, 330 | delTaskId, 331 | setTaskIds, 332 | setAutoTranslate, 333 | setAutoScroll, 334 | setNoVideo, 335 | setReviewAction, 336 | setNeedScroll, 337 | setCurIdx, 338 | setEnvData, 339 | setEnvReady, 340 | setInfos, 341 | setCurInfo, 342 | setCurFetched, 343 | setData, 344 | setFold, 345 | setSearchText, 346 | setSearchResult, 347 | setInputting, 348 | addAskInfo, 349 | delAskInfo, 350 | mergeAskInfo, 351 | setCtime, 352 | setAuthor, 353 | setChapters, 354 | } = slice.actions 355 | 356 | export default slice.reducer 357 | -------------------------------------------------------------------------------- /src/utils/bizUtil.ts: -------------------------------------------------------------------------------- 1 | import {APP_DOM_ID, CUSTOM_MODEL_TOKENS, MODEL_DEFAULT, MODEL_MAP, SUMMARIZE_TYPES} from '../consts/const' 2 | import {isDarkMode} from '../utils/env_util' 3 | import toast from 'react-hot-toast' 4 | import {findIndex} from 'lodash-es' 5 | export const debug = (...args: any[]) => { 6 | console.debug('[APP]', ...args) 7 | } 8 | 9 | /** 10 | * 获取译文 11 | */ 12 | export const getTransText = (transResult: TransResult, hideOnDisableAutoTranslate: boolean | undefined, autoTranslate: boolean | undefined) => { 13 | if (transResult && (!transResult.code || transResult.code === '200') && (autoTranslate === true || !hideOnDisableAutoTranslate) && transResult.data) { 14 | return transResult.data 15 | } 16 | } 17 | 18 | export const getDisplay = (transDisplay_: EnvData['transDisplay'], content: string, transText: string | undefined) => { 19 | const transDisplay = transDisplay_ ?? 'originPrimary' 20 | let main, sub 21 | // main 22 | if (transText && (transDisplay === 'targetPrimary' || transDisplay === 'target')) { 23 | main = transText 24 | } else { 25 | main = content 26 | } 27 | // sub 28 | switch (transDisplay) { 29 | case 'originPrimary': 30 | sub = transText 31 | break 32 | case 'targetPrimary': 33 | if (transText) { 34 | sub = content 35 | } 36 | break 37 | default: 38 | break 39 | } 40 | // return 41 | return { 42 | main, 43 | sub, 44 | } 45 | } 46 | 47 | export const getWholeText = (items: string[]) => { 48 | return items.join(',').replaceAll('\n', ' ') 49 | } 50 | 51 | export const getLastTime = (seconds: number) => { 52 | if (seconds > 60 * 60) { 53 | return `${Math.floor(seconds / 60 / 60)}小时` 54 | } 55 | if (seconds > 60) { 56 | return `${Math.floor(seconds / 60)}分钟` 57 | } 58 | return `${Math.floor(seconds)}秒` 59 | } 60 | 61 | /** 62 | * 00:00:00 63 | */ 64 | export const getTimeDisplay = (seconds: number) => { 65 | const h = Math.floor(seconds / 60 / 60) 66 | const m = Math.floor(seconds / 60 % 60) 67 | const s = Math.floor(seconds % 60) 68 | return `${h < 10 ? '0' : ''}${h}:${m < 10 ? '0' : ''}${m}:${s < 10 ? '0' : ''}${s}` 69 | } 70 | 71 | export const isSummaryEmpty = (summary: Summary) => { 72 | if (summary.type === 'overview') { 73 | const content: OverviewItem[] = summary.content??[] 74 | return content.length === 0 75 | } else if (summary.type === 'keypoint') { 76 | const content: string[] = summary.content??[] 77 | return content.length === 0 78 | } else if (summary.type === 'brief') { 79 | const content: string[] = summary.content??'' 80 | return content.length === 0 81 | } else if (summary.type === 'question') { 82 | const content: any[] = summary.content??[] 83 | return content.length === 0 84 | } else if (summary.type === 'debate') { 85 | const content: Array<{ side: string, content: string }> = summary.content ?? [] 86 | return content.length === 0 87 | } 88 | return true 89 | } 90 | 91 | export const getSummaryStr = (summary: Summary) => { 92 | let s = '' 93 | if (summary.type === 'overview') { 94 | const content: OverviewItem[] = summary.content ?? [] 95 | for (const overviewItem of content) { 96 | s += (overviewItem.emoji ?? '') + overviewItem.time + ' ' + overviewItem.key + '\n' 97 | } 98 | } else if (summary.type === 'keypoint') { 99 | const content: string[] = summary.content ?? [] 100 | for (const keypoint of content) { 101 | s += '- ' + keypoint + '\n' 102 | } 103 | } else if (summary.type === 'brief') { 104 | const content: { summary: string } = summary.content ?? { 105 | summary: '' 106 | } 107 | s += content.summary + '\n' 108 | } else if (summary.type === 'question') { 109 | const content: Array<{ q: string, a: string }> = summary.content ?? [] 110 | s += content.map(item => { 111 | return item.q + '\n' + item.a + '\n' 112 | }).join('\n') 113 | } else if (summary.type === 'debate') { 114 | const content: Array<{ side: string, content: string }> = summary.content ?? [] 115 | s += content.map(item => { 116 | return (item.side === 'pro'?'正方:':'反方:') + item.content + '\n' 117 | }).join('\n') 118 | } 119 | return s 120 | } 121 | 122 | export const getServerUrl = (serverUrl?: string) => { 123 | if (!serverUrl) { 124 | return 'https://api.openai.com' 125 | } 126 | if (serverUrl.endsWith('/')) { 127 | serverUrl = serverUrl.slice(0, -1) 128 | } 129 | return serverUrl 130 | } 131 | 132 | export const getModel = (envData: EnvData) => { 133 | if (envData.model === 'custom') { 134 | return envData.customModel 135 | } else { 136 | return envData.model 137 | } 138 | } 139 | 140 | export const getModelMaxTokens = (envData: EnvData) => { 141 | if (envData.model === 'custom') { 142 | return envData.customModelTokens??CUSTOM_MODEL_TOKENS 143 | } else { 144 | return MODEL_MAP[envData.model??MODEL_DEFAULT]?.tokens??4000 145 | } 146 | } 147 | 148 | export const setTheme = (theme: EnvData['theme']) => { 149 | const appRoot = document.getElementById(APP_DOM_ID) 150 | if (appRoot != null) { 151 | // system 152 | theme = theme ?? 'system' 153 | if (!theme || theme === 'system') { 154 | theme = isDarkMode() ? 'dark' : 'light' 155 | } 156 | 157 | appRoot.setAttribute('data-theme', theme) 158 | if (theme === 'dark') { 159 | appRoot.classList.add('dark') 160 | appRoot.classList.remove('light') 161 | } else { 162 | appRoot.classList.add('light') 163 | appRoot.classList.remove('dark') 164 | } 165 | } 166 | } 167 | 168 | export const getSummarize = (title: string | undefined, segments: Segment[] | undefined, type: SummaryType): [boolean, string] => { 169 | if (segments == null) { 170 | return [false, ''] 171 | } 172 | 173 | let content = `${SUMMARIZE_TYPES[type]?.downloadName ?? ''}\n\n` 174 | let success = false 175 | for (const segment of segments) { 176 | const summary = segment.summaries[type] 177 | if (summary && !isSummaryEmpty(summary)) { 178 | success = true 179 | content += getSummaryStr(summary) 180 | } else { 181 | if (segment.items.length > 0) { 182 | content += `${getTimeDisplay(segment.items[0].from)} ` 183 | } 184 | content += '未总结\n' 185 | } 186 | } 187 | 188 | content += '\n--- 哔哩哔哩字幕列表扩展' 189 | 190 | if (!success) { 191 | toast.error('未找到总结') 192 | } 193 | 194 | return [success, content] 195 | } 196 | 197 | /** 198 | * 将 MM:SS 或 HH:MM:SS 格式的时间字符串转换为总秒数。 199 | * @param time '03:10' 或 '01:03:10' 格式的时间字符串 200 | * @returns number 总秒数,如果格式无效则返回 0 或 NaN (根据下面选择)。 201 | * 建议添加更严格的错误处理,例如抛出错误。 202 | */ 203 | export const parseStrTimeToSeconds = (time: string): number => { 204 | // 1. 基本输入验证 (可选但推荐) 205 | if (!time || typeof time !== 'string') { 206 | console.warn(`Invalid input type for time: ${typeof time}`) 207 | return 0 // 或者 return NaN; 208 | } 209 | 210 | const parts = time.split(':') 211 | const partCount = parts.length 212 | 213 | let hours = 0 214 | let minutes = 0 215 | let seconds = 0 216 | 217 | try { 218 | if (partCount === 2) { 219 | // 格式: MM:SS 220 | minutes = parseInt(parts[0]) 221 | seconds = parseInt(parts[1]) 222 | } else if (partCount === 3) { 223 | // 格式: HH:MM:SS 224 | hours = parseInt(parts[0]) 225 | minutes = parseInt(parts[1]) 226 | seconds = parseInt(parts[2]) 227 | } else { 228 | // 格式无效 229 | console.warn(`Invalid time format: "${time}". Expected MM:SS or HH:MM:SS.`) 230 | return 0 // 或者 return NaN; 231 | } 232 | 233 | // 2. 验证解析出的部分是否为有效数字 234 | if (isNaN(hours) || isNaN(minutes) || isNaN(seconds)) { 235 | console.warn(`Invalid numeric values in time string: "${time}"`) 236 | return 0 // 或者 return NaN; 237 | } 238 | 239 | // 3. 计算总秒数 240 | return hours * 3600 + minutes * 60 + seconds 241 | } catch (error) { 242 | // 捕获潜在的错误 (虽然在此逻辑中不太可能,但以防万一) 243 | console.error(`Error parsing time string: "${time}"`, error) 244 | return 0 // 或者 return NaN; 245 | } 246 | } 247 | 248 | /** 249 | * @param time '00:04:11,599' or '00:04:11.599' or '04:11,599' or '04:11.599' 250 | * @return seconds, 4.599 251 | */ 252 | export const parseTime = (time: string): number => { 253 | const separator = time.includes(',') ? ',' : '.' 254 | const parts = time.split(':') 255 | const ms = parts[parts.length-1].split(separator) 256 | if (parts.length === 3) { 257 | return parseInt(parts[0]) * 60 * 60 + parseInt(parts[1]) * 60 + parseInt(ms[0]) + parseInt(ms[1]) / 1000 258 | } else { 259 | return parseInt(parts[0]) * 60 + parseInt(ms[0]) + parseInt(ms[1]) / 1000 260 | } 261 | } 262 | 263 | export const parseTranscript = (filename: string, text: string | ArrayBuffer): Transcript => { 264 | const items: TranscriptItem[] = [] 265 | // convert /r/n to /n 266 | text = (text as string).trim().replace(/\r\n/g, '\n') 267 | // .srt: 268 | if (filename.toLowerCase().endsWith('.srt')) { 269 | const lines = text.split('\n\n') 270 | for (const line of lines) { 271 | try { 272 | const linesInner = line.trim().split('\n') 273 | if (linesInner.length >= 3) { 274 | const time = linesInner[1].split(' --> ') 275 | const from = parseTime(time[0]) 276 | const to = parseTime(time[1]) 277 | const content = linesInner.slice(2).join('\n') 278 | items.push({ 279 | from, 280 | to, 281 | content, 282 | idx: items.length, 283 | }) 284 | } 285 | } catch (e) { 286 | console.error('parse error', line) 287 | } 288 | } 289 | } 290 | // .vtt: 291 | if (filename.toLowerCase().endsWith('.vtt')) { 292 | const lines = text.split('\n\n') 293 | for (const line of lines) { 294 | const lines = line.split('\n') 295 | const timeIdx = findIndex(lines, (line) => line.includes('-->')) 296 | if (timeIdx >= 0) { 297 | const time = lines[timeIdx].split(' --> ') 298 | const from = parseTime(time[0]) 299 | const to = parseTime(time[1]) 300 | const content = lines.slice(timeIdx + 1).join('\n') 301 | items.push({ 302 | from, 303 | to, 304 | content, 305 | idx: items.length, 306 | }) 307 | } 308 | } 309 | } 310 | // return 311 | return { 312 | body: items, 313 | } 314 | } 315 | 316 | export const extractJsonObject = (content: string) => { 317 | // get content between ``` and ``` 318 | const start = content.indexOf('```') 319 | const end = content.lastIndexOf('```') 320 | if (start >= 0 && end >= 0) { 321 | if (start === end) { // 异常情况 322 | if (content.startsWith('```')) { 323 | content = content.slice(3) 324 | } else { 325 | content = content.slice(0, -3) 326 | } 327 | } else { 328 | content = content.slice(start + 3, end) 329 | } 330 | } 331 | // get content between { and } 332 | const start2 = content.indexOf('{') 333 | const end2 = content.lastIndexOf('}') 334 | if (start2 >= 0 && end2 >= 0) { 335 | content = content.slice(start2, end2 + 1) 336 | } 337 | return content 338 | } 339 | 340 | export const extractJsonArray = (content: string) => { 341 | // get content between ``` and ``` 342 | const start = content.indexOf('```') 343 | const end = content.lastIndexOf('```') 344 | if (start >= 0 && end >= 0) { 345 | if (start === end) { // 异常情况 346 | if (content.startsWith('```')) { 347 | content = content.slice(3) 348 | } else { 349 | content = content.slice(0, -3) 350 | } 351 | } else { 352 | content = content.slice(start + 3, end) 353 | } 354 | } 355 | // get content between [ and ] 356 | const start3 = content.indexOf('[') 357 | const end3 = content.lastIndexOf(']') 358 | if (start3 >= 0 && end3 >= 0) { 359 | content = content.slice(start3, end3 + 1) 360 | } 361 | return content 362 | } 363 | -------------------------------------------------------------------------------- /src/hooks/useTranslate.ts: -------------------------------------------------------------------------------- 1 | import {useAppDispatch, useAppSelector} from './redux' 2 | import {useCallback} from 'react' 3 | import { 4 | addTaskId, 5 | addTransResults, 6 | delTaskId, 7 | mergeAskInfo, 8 | setLastSummarizeTime, 9 | setLastTransTime, 10 | setSummaryContent, 11 | setSummaryError, 12 | setSummaryStatus, 13 | setReviewAction, 14 | setTempData 15 | } from '../redux/envReducer' 16 | import { 17 | LANGUAGE_DEFAULT, 18 | LANGUAGES_MAP, 19 | PROMPT_DEFAULTS, 20 | PROMPT_TYPE_ASK, 21 | PROMPT_TYPE_TRANSLATE, 22 | SUMMARIZE_LANGUAGE_DEFAULT, 23 | SUMMARIZE_THRESHOLD, 24 | SUMMARIZE_TYPES, 25 | TRANSLATE_COOLDOWN, 26 | TRANSLATE_FETCH_DEFAULT, 27 | } from '../consts/const' 28 | import toast from 'react-hot-toast' 29 | import {useMemoizedFn} from 'ahooks/es' 30 | import {extractJsonArray, extractJsonObject, getModel} from '../utils/bizUtil' 31 | import {formatTime} from '../utils/util' 32 | import { useMessage } from './useMessageService' 33 | const useTranslate = () => { 34 | const dispatch = useAppDispatch() 35 | const data = useAppSelector(state => state.env.data) 36 | const curIdx = useAppSelector(state => state.env.curIdx) 37 | const lastTransTime = useAppSelector(state => state.env.lastTransTime) 38 | const transResults = useAppSelector(state => state.env.transResults) 39 | const envData = useAppSelector(state => state.env.envData) 40 | const language = LANGUAGES_MAP[envData.language??LANGUAGE_DEFAULT] 41 | const summarizeLanguage = LANGUAGES_MAP[envData.summarizeLanguage??SUMMARIZE_LANGUAGE_DEFAULT] 42 | const title = useAppSelector(state => state.env.title) 43 | const reviewed = useAppSelector(state => state.env.tempData.reviewed) 44 | const reviewAction = useAppSelector(state => state.env.reviewAction) 45 | const reviewActions = useAppSelector(state => state.env.tempData.reviewActions) 46 | const {sendExtension} = useMessage(!!envData.sidePanel) 47 | /** 48 | * 获取下一个需要翻译的行 49 | * 会检测冷却 50 | */ 51 | const getFetch = useCallback(() => { 52 | if (data?.body != null && data.body.length > 0) { 53 | const curIdx_ = curIdx ?? 0 54 | 55 | // check lastTransTime 56 | if (lastTransTime && Date.now() - lastTransTime < TRANSLATE_COOLDOWN) { 57 | return 58 | } 59 | 60 | let nextIdleIdx 61 | for (let i = curIdx_; i < data.body.length; i++) { 62 | if (transResults[i] == null) { 63 | nextIdleIdx = i 64 | break 65 | } 66 | } 67 | if (nextIdleIdx != null && nextIdleIdx - curIdx_ <= Math.ceil((envData.fetchAmount??TRANSLATE_FETCH_DEFAULT)/2)) { 68 | return nextIdleIdx 69 | } 70 | } 71 | }, [curIdx, data?.body, envData.fetchAmount, lastTransTime, transResults]) 72 | 73 | const addTask = useCallback(async (startIdx: number) => { 74 | if ((data?.body) != null) { 75 | const lines: string[] = data.body.slice(startIdx, startIdx + (envData.fetchAmount??TRANSLATE_FETCH_DEFAULT)).map((item: any) => item.content) 76 | if (lines.length > 0) { 77 | const linesMap: {[key: string]: string} = {} 78 | lines.forEach((line, idx) => { 79 | linesMap[(idx + 1)+''] = line 80 | }) 81 | let lineStr = JSON.stringify(linesMap).replaceAll('\n', '') 82 | lineStr = '```' + lineStr + '```' 83 | 84 | let prompt: string = envData.prompts?.[PROMPT_TYPE_TRANSLATE]??PROMPT_DEFAULTS[PROMPT_TYPE_TRANSLATE] 85 | // replace params 86 | prompt = prompt.replaceAll('{{language}}', language.name) 87 | prompt = prompt.replaceAll('{{title}}', title??'') 88 | prompt = prompt.replaceAll('{{subtitles}}', lineStr) 89 | 90 | const taskDef: TaskDef = { 91 | type: 'chatComplete', 92 | serverUrl: envData.serverUrl, 93 | data: { 94 | model: getModel(envData), 95 | messages: [ 96 | { 97 | role: 'user', 98 | content: prompt, 99 | } 100 | ], 101 | temperature: 0.25, 102 | n: 1, 103 | stream: false, 104 | }, 105 | extra: { 106 | type: 'translate', 107 | apiKey: envData.apiKey, 108 | startIdx, 109 | size: lines.length, 110 | } 111 | } 112 | console.debug('addTask', taskDef) 113 | dispatch(setLastTransTime(Date.now())) 114 | // addTransResults 115 | const result: { [key: number]: TransResult } = {} 116 | lines.forEach((line, idx) => { 117 | result[startIdx + idx] = { 118 | // idx: startIdx + idx, 119 | } 120 | }) 121 | dispatch(addTransResults(result)) 122 | const task = await sendExtension(null, 'ADD_TASK', {taskDef}) 123 | dispatch(addTaskId(task.id)) 124 | } 125 | } 126 | }, [data?.body, envData, language.name, title, dispatch, sendExtension]) 127 | 128 | const addSummarizeTask = useCallback(async (type: SummaryType, segment: Segment) => { 129 | // review action 130 | if (reviewed === undefined && !reviewAction) { 131 | dispatch(setReviewAction(true)) 132 | dispatch(setTempData({ 133 | reviewActions: (reviewActions ?? 0) + 1 134 | })) 135 | } 136 | 137 | if (segment.text.length >= SUMMARIZE_THRESHOLD) { 138 | let subtitles = '' 139 | for (const item of segment.items) { 140 | subtitles += formatTime(item.from) + ' ' + item.content + '\n' 141 | } 142 | // @ts-expect-error 143 | const promptType: keyof typeof PROMPT_DEFAULTS = SUMMARIZE_TYPES[type].promptType 144 | let prompt: string = envData.prompts?.[promptType]??PROMPT_DEFAULTS[promptType] 145 | // replace params 146 | prompt = prompt.replaceAll('{{language}}', summarizeLanguage.name) 147 | prompt = prompt.replaceAll('{{title}}', title??'') 148 | prompt = prompt.replaceAll('{{subtitles}}', subtitles) 149 | prompt = prompt.replaceAll('{{segment}}', segment.text) 150 | 151 | const taskDef: TaskDef = { 152 | type: 'chatComplete', 153 | serverUrl: envData.serverUrl, 154 | data: { 155 | model: getModel(envData), 156 | messages: [ 157 | { 158 | role: 'user', 159 | content: prompt, 160 | } 161 | ], 162 | temperature: 0.5, 163 | n: 1, 164 | stream: false, 165 | }, 166 | extra: { 167 | type: 'summarize', 168 | summaryType: type, 169 | startIdx: segment.startIdx, 170 | apiKey: envData.apiKey, 171 | } 172 | } 173 | console.debug('addSummarizeTask', taskDef) 174 | dispatch(setSummaryStatus({segmentStartIdx: segment.startIdx, type, status: 'pending'})) 175 | dispatch(setLastSummarizeTime(Date.now())) 176 | const task = await sendExtension(null, 'ADD_TASK', {taskDef}) 177 | dispatch(addTaskId(task.id)) 178 | } 179 | }, [dispatch, envData, reviewAction, reviewActions, reviewed, sendExtension, summarizeLanguage.name, title]) 180 | 181 | const addAskTask = useCallback(async (id: string, segment: Segment, question: string) => { 182 | if (segment.text.length >= SUMMARIZE_THRESHOLD) { 183 | let prompt: string = envData.prompts?.[PROMPT_TYPE_ASK]??PROMPT_DEFAULTS[PROMPT_TYPE_ASK] 184 | // replace params 185 | prompt = prompt.replaceAll('{{language}}', summarizeLanguage.name) 186 | prompt = prompt.replaceAll('{{title}}', title??'') 187 | prompt = prompt.replaceAll('{{segment}}', segment.text) 188 | prompt = prompt.replaceAll('{{question}}', question) 189 | 190 | const taskDef: TaskDef = { 191 | type: 'chatComplete', 192 | serverUrl: envData.serverUrl, 193 | data: { 194 | model: getModel(envData), 195 | messages: [ 196 | { 197 | role: 'user', 198 | content: prompt, 199 | } 200 | ], 201 | temperature: 0.5, 202 | n: 1, 203 | stream: false, 204 | }, 205 | extra: { 206 | type: 'ask', 207 | // startIdx: segment.startIdx, 208 | apiKey: envData.apiKey, 209 | askId: id, 210 | } 211 | } 212 | console.debug('addAskTask', taskDef) 213 | dispatch(mergeAskInfo({ 214 | id, 215 | status: 'pending' 216 | })) 217 | const task = await sendExtension(null, 'ADD_TASK', {taskDef}) 218 | dispatch(addTaskId(task.id)) 219 | } 220 | }, [dispatch, envData, sendExtension, summarizeLanguage.name, title]) 221 | 222 | const handleTranslate = useMemoizedFn((task: Task, content: string) => { 223 | let map: {[key: string]: string} = {} 224 | try { 225 | content = extractJsonObject(content) 226 | map = JSON.parse(content) 227 | } catch (e) { 228 | console.debug(e) 229 | } 230 | const {startIdx, size} = task.def.extra 231 | if (startIdx != null) { 232 | const result: { [key: number]: TransResult } = {} 233 | for (let i = 0; i < size; i++) { 234 | const item = map[(i + 1)+''] 235 | if (item) { 236 | result[startIdx + i] = { 237 | // idx: startIdx + i, 238 | code: '200', 239 | data: item, 240 | } 241 | } else { 242 | result[startIdx + i] = { 243 | // idx: startIdx + i, 244 | code: '500', 245 | } 246 | } 247 | } 248 | dispatch(addTransResults(result)) 249 | console.debug('addTransResults', map, size) 250 | } 251 | }) 252 | 253 | const handleSummarize = useMemoizedFn((task: Task, content?: string) => { 254 | const summaryType = task.def.extra.summaryType 255 | content = summaryType === 'brief'?extractJsonObject(content??''):extractJsonArray(content??'') 256 | let obj 257 | try { 258 | obj = JSON.parse(content) 259 | } catch (e) { 260 | task.error = 'failed' 261 | } 262 | 263 | dispatch(setSummaryContent({ 264 | segmentStartIdx: task.def.extra.startIdx, 265 | type: summaryType, 266 | content: obj, 267 | })) 268 | dispatch(setSummaryStatus({segmentStartIdx: task.def.extra.startIdx, type: summaryType, status: 'done'})) 269 | dispatch(setSummaryError({segmentStartIdx: task.def.extra.startIdx, type: summaryType, error: task.error})) 270 | console.debug('setSummary', task.def.extra.startIdx, summaryType, obj, task.error) 271 | }) 272 | 273 | const handleAsk = useMemoizedFn((task: Task, content?: string) => { 274 | dispatch(mergeAskInfo({ 275 | id: task.def.extra.askId, 276 | content, 277 | status: 'done', 278 | error: task.error, 279 | })) 280 | 281 | console.debug('setAsk', content, task.error) 282 | }) 283 | 284 | const getTask = useCallback(async (taskId: string) => { 285 | const taskResp = await sendExtension(null, 'GET_TASK', {taskId}) 286 | if (taskResp.code === 'ok') { 287 | console.debug('getTask', taskResp.task) 288 | const task: Task = taskResp.task 289 | const taskType: string | undefined = task.def.extra?.type 290 | const content = task.resp?.choices?.[0]?.message?.content?.trim() 291 | if (task.status === 'done') { 292 | // 异常提示 293 | if (task.error) { 294 | toast.error(task.error) 295 | } 296 | // 删除任务 297 | dispatch(delTaskId(taskId)) 298 | // 处理结果 299 | if (taskType === 'translate') { // 翻译 300 | handleTranslate(task, content) 301 | } else if (taskType === 'summarize') { // 总结 302 | handleSummarize(task, content) 303 | } else if (taskType === 'ask') { // 总结 304 | handleAsk(task, content) 305 | } 306 | } 307 | } else { 308 | dispatch(delTaskId(taskId)) 309 | } 310 | }, [dispatch, handleAsk, handleSummarize, handleTranslate, sendExtension]) 311 | 312 | return {getFetch, getTask, addTask, addSummarizeTask, addAskTask} 313 | } 314 | 315 | export default useTranslate 316 | -------------------------------------------------------------------------------- /src/components/MoreBtn.tsx: -------------------------------------------------------------------------------- 1 | import {MouseEvent, useCallback, useContext, useRef, useState} from 'react' 2 | import {useClickAway} from 'ahooks' 3 | import { 4 | FiMoreVertical, 5 | ImDownload3, 6 | IoMdSettings, 7 | RiFileCopy2Line 8 | } from 'react-icons/all' 9 | import Popover from '../components/Popover' 10 | import {Placement} from '@popperjs/core/lib/enums' 11 | import {useAppDispatch, useAppSelector} from '../hooks/redux' 12 | import {setEnvData, setTempData} from '../redux/envReducer' 13 | import {EventBusContext} from '../Router' 14 | import {EVENT_EXPAND} from '../consts/const' 15 | import {formatSrtTime, formatTime, formatVttTime, downloadText} from '../utils/util' 16 | import {openUrl} from '../utils/env_util' 17 | import toast from 'react-hot-toast' 18 | import {getSummarize} from '../utils/bizUtil' 19 | import dayjs from 'dayjs' 20 | import { useMessage } from '@/hooks/useMessageService' 21 | 22 | interface Props { 23 | placement: Placement 24 | } 25 | 26 | const DownloadTypes = [ 27 | { 28 | type: 'text', 29 | name: '列表', 30 | }, 31 | { 32 | type: 'textWithTime', 33 | name: '列表(带时间)', 34 | }, 35 | { 36 | type: 'article', 37 | name: '文章', 38 | }, 39 | { 40 | type: 'srt', 41 | name: 'srt', 42 | }, 43 | { 44 | type: 'vtt', 45 | name: 'vtt', 46 | }, 47 | { 48 | type: 'json', 49 | name: '原始json', 50 | }, 51 | { 52 | type: 'summarize', 53 | name: '总结', 54 | }, 55 | ] 56 | 57 | const MoreBtn = (props: Props) => { 58 | const {placement} = props 59 | const dispatch = useAppDispatch() 60 | 61 | const moreRef = useRef(null) 62 | const data = useAppSelector(state => state.env.data) 63 | const envReady = useAppSelector(state => state.env.envReady) 64 | const envData = useAppSelector(state => state.env.envData) 65 | const downloadType = useAppSelector(state => state.env.tempData.downloadType) 66 | const [moreVisible, setMoreVisible] = useState(false) 67 | const eventBus = useContext(EventBusContext) 68 | const segments = useAppSelector(state => state.env.segments) 69 | const url = useAppSelector(state => state.env.url) 70 | const title = useAppSelector(state => state.env.title) 71 | const ctime = useAppSelector(state => state.env.ctime) // 时间戳,单位s 72 | const author = useAppSelector(state => state.env.author) 73 | const curSummaryType = useAppSelector(state => state.env.tempData.curSummaryType) 74 | 75 | const {sendInject} = useMessage(!!envData.sidePanel) 76 | 77 | const downloadCallback = useCallback((download: boolean) => { 78 | if (data == null) { 79 | return 80 | } 81 | 82 | let fileName = title 83 | let s, suffix 84 | const time = ctime ? dayjs(ctime * 1000).format('YYYY-MM-DD HH:mm:ss') : '' // 2024-05-01 12:00:00 85 | if (!downloadType || downloadType === 'text') { 86 | s = `${title??'无标题'}\n${url??'无链接'}\n${author??'无作者'} ${time}\n\n` 87 | for (const item of data.body) { 88 | s += item.content + '\n' 89 | } 90 | suffix = 'txt' 91 | } else if (downloadType === 'textWithTime') { 92 | s = `${title??'无标题'}\n${url??'无链接'}\n${author??'无作者'} ${time}\n\n` 93 | for (const item of data.body) { 94 | s += formatTime(item.from) + ' ' + item.content + '\n' 95 | } 96 | suffix = 'txt' 97 | } else if (downloadType === 'article') { 98 | s = `${title??'无标题'}\n${url??'无链接'}\n${author??'无作者'} ${time}\n\n` 99 | for (const item of data.body) { 100 | s += item.content + ', ' 101 | } 102 | s = s.substring(0, s.length - 1) // remove last ',' 103 | suffix = 'txt' 104 | } else if (downloadType === 'srt') { 105 | /** 106 | * 1 107 | * 00:05:00,400 --> 00:05:15,300 108 | * This is an example of 109 | * a subtitle. 110 | * 111 | * 2 112 | * 00:05:16,400 --> 00:05:25,300 113 | * This is an example of 114 | * a subtitle - 2nd subtitle. 115 | */ 116 | s = '' 117 | for (const item of data.body) { 118 | const ss = (item.idx + 1) + '\n' + formatSrtTime(item.from) + ' --> ' + formatSrtTime(item.to) + '\n' + ((item.content?.trim()) ?? '') + '\n\n' 119 | s += ss 120 | } 121 | s = s.substring(0, s.length - 1)// remove last '\n' 122 | suffix = 'srt' 123 | } else if (downloadType === 'vtt') { 124 | /** 125 | * WEBVTT title 126 | * 127 | * 1 128 | * 00:05:00.400 --> 00:05:15.300 129 | * This is an example of 130 | * a subtitle. 131 | * 132 | * 2 133 | * 00:05:16.400 --> 00:05:25.300 134 | * This is an example of 135 | * a subtitle - 2nd subtitle. 136 | */ 137 | s = `WEBVTT ${title ?? ''}\n\n` 138 | for (const item of data.body) { 139 | const ss = (item.idx + 1) + '\n' + formatVttTime(item.from) + ' --> ' + formatVttTime(item.to) + '\n' + ((item.content?.trim()) ?? '') + '\n\n' 140 | s += ss 141 | } 142 | s = s.substring(0, s.length - 1)// remove last '\n' 143 | suffix = 'vtt' 144 | } else if (downloadType === 'json') { 145 | s = JSON.stringify(data) 146 | suffix = 'json' 147 | } else if (downloadType === 'summarize') { 148 | s = `${title??'无标题'}\n${url??'无链接'}\n${author??'无作者'} ${time}\n\n` 149 | const [success, content] = getSummarize(title, segments, curSummaryType) 150 | if (!success) return 151 | s += content 152 | fileName += ' - 总结' 153 | suffix = 'txt' 154 | } else { 155 | return 156 | } 157 | if (download) { 158 | downloadText(s, fileName+'.'+suffix) 159 | } else { 160 | navigator.clipboard.writeText(s).then(() => { 161 | toast.success('复制成功') 162 | }).catch(console.error) 163 | } 164 | setMoreVisible(false) 165 | }, [author, ctime, curSummaryType, data, downloadType, segments, title, url]) 166 | 167 | const downloadAudioCallback = useCallback(() => { 168 | sendInject(null, 'DOWNLOAD_AUDIO', {}) 169 | }, [sendInject]) 170 | 171 | const selectCallback = useCallback((e: any) => { 172 | dispatch(setTempData({ 173 | downloadType: e.target.value, 174 | })) 175 | }, [dispatch]) 176 | 177 | const preventCallback = useCallback((e: any) => { 178 | e.stopPropagation() 179 | }, []) 180 | 181 | const moreCallback = useCallback((e: MouseEvent) => { 182 | e.stopPropagation() 183 | if (!envData.flagDot) { 184 | dispatch(setEnvData({ 185 | ...envData, 186 | flagDot: true, 187 | })) 188 | } 189 | setMoreVisible(!moreVisible) 190 | // 显示菜单时自动展开,防止菜单显示不全 191 | if (!moreVisible) { 192 | eventBus.emit({ 193 | type: EVENT_EXPAND 194 | }) 195 | } 196 | }, [dispatch, envData, eventBus, moreVisible]) 197 | useClickAway(() => { 198 | setMoreVisible(false) 199 | }, moreRef) 200 | 201 | return <> 202 |
203 |
204 | {envReady && !envData.flagDot && } 205 | 206 |
207 |
208 | {moreVisible && 209 | 212 | 315 | } 316 | 317 | } 318 | 319 | export default MoreBtn 320 | -------------------------------------------------------------------------------- /src/utils/util.ts: -------------------------------------------------------------------------------- 1 | import {SyntheticEvent} from 'react' 2 | import {omitBy} from 'lodash-es' 3 | 4 | export const isEdgeBrowser = () => { 5 | const userAgent = navigator.userAgent.toLowerCase() 6 | return userAgent.includes('edg/') && !userAgent.includes('edge/') 7 | } 8 | 9 | /** 10 | * 将总秒数格式化为 MM:SS 或 HH:MM:SS 格式的字符串。 11 | * 如果时间小于 1 小时,则使用 MM:SS 格式。 12 | * 如果时间大于或等于 1 小时,则使用 HH:MM:SS 格式。 13 | * 14 | * @param time 总秒数 (number) 15 | * @returns string 格式化后的时间字符串 ('MM:SS' 或 'HH:MM:SS') 16 | */ 17 | export const formatTime = (time: number): string => { 18 | // 1. 输入验证和处理 0 或负数的情况 19 | if (typeof time !== 'number' || isNaN(time) || time <= 0) { 20 | return '00:00' // 对于无效输入、0 或负数,返回 '00:00' 21 | } 22 | 23 | // 取整确保我们处理的是整数秒 24 | const totalSeconds = Math.floor(time) 25 | 26 | // 2. 计算小时、分钟和秒 27 | const hours = Math.floor(totalSeconds / 3600) 28 | const minutes = Math.floor((totalSeconds % 3600) / 60) 29 | const seconds = totalSeconds % 60 30 | 31 | // 3. 格式化各个部分,确保是两位数 (例如 0 -> '00', 5 -> '05', 10 -> '10') 32 | const formattedSeconds = seconds.toString().padStart(2, '0') 33 | const formattedMinutes = minutes.toString().padStart(2, '0') 34 | 35 | // 4. 根据是否有小时来决定最终格式 36 | if (hours > 0) { 37 | const formattedHours = hours.toString().padStart(2, '0') 38 | return `${formattedHours}:${formattedMinutes}:${formattedSeconds}` 39 | } else { 40 | return `${formattedMinutes}:${formattedSeconds}` 41 | } 42 | } 43 | 44 | /** 45 | * @param time 2.82 46 | */ 47 | export const formatSrtTime = (time: number) => { 48 | if (!time) return '00:00:00,000' 49 | 50 | const hours = Math.floor(time / 60 / 60) 51 | const minutes = Math.floor(time / 60 % 60) 52 | const seconds = Math.floor(time % 60) 53 | const ms = Math.floor((time % 1) * 1000) 54 | return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')},${ms.toString().padStart(3, '0')}` 55 | } 56 | 57 | /** 58 | * @param time 2.82 59 | */ 60 | export const formatVttTime = (time: number) => { 61 | if (!time) return '00:00:00.000' 62 | 63 | const hours = Math.floor(time / 60 / 60) 64 | const minutes = Math.floor(time / 60 % 60) 65 | const seconds = Math.floor(time % 60) 66 | const ms = Math.floor((time % 1) * 1000) 67 | return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}` 68 | } 69 | 70 | export const preventFunc = (e: SyntheticEvent) => { 71 | e.preventDefault() 72 | } 73 | 74 | export const stopPopFunc = (e: SyntheticEvent) => { 75 | e.stopPropagation() 76 | } 77 | 78 | /** 79 | * @return yyyy-MM-dd 80 | */ 81 | export const getDay = (timeInMills: number) => { 82 | const date = new Date(timeInMills) 83 | return date.toISOString().substring(0, 10) 84 | } 85 | 86 | export const styleNames = (style: Object): Object => { 87 | return omitBy(style, (k: any) => k === undefined || k === null || k === false) 88 | } 89 | 90 | /** 91 | * 处理json数据,递归删除所有__开头的属性名 92 | * @return 本身 93 | */ 94 | export const handleJson = (json: any) => { 95 | for (const key in json) { 96 | if (key.startsWith('__')) { // 删除属性 97 | // eslint-disable-next-line @typescript-eslint/no-dynamic-delete 98 | delete json[key] 99 | } else { 100 | const value = json[key] 101 | if (typeof value === 'object') { 102 | handleJson(value) 103 | } 104 | } 105 | } 106 | return json 107 | } 108 | 109 | /** 110 | * 连接url 111 | */ 112 | export const combineUrl = (parentPath: string, path?: string) => { 113 | if (!path) { 114 | return parentPath 115 | } 116 | 117 | let splashCnt = 0 118 | if (parentPath.endsWith('/')) { 119 | splashCnt++ 120 | } 121 | if (path.startsWith('/')) { 122 | splashCnt++ 123 | } 124 | if (splashCnt === 1) { 125 | return parentPath+path 126 | } else if (splashCnt === 2) { 127 | return parentPath+path.slice(1) 128 | } else { 129 | return parentPath+'/'+path 130 | } 131 | } 132 | 133 | /** 134 | * 获取图标url 135 | */ 136 | export const getIconUrl = (bookmarkUrlStr: string, icon?: string) => { 137 | let bookmarkUrl 138 | try { 139 | bookmarkUrl = new URL(fixUrl(bookmarkUrlStr)) 140 | } catch (e) { 141 | console.error(e) 142 | return icon 143 | } 144 | if (icon) { 145 | if (icon.startsWith('//')) { 146 | return combineUrl(bookmarkUrl.protocol, icon) 147 | } else if (icon.startsWith('/')) { 148 | return combineUrl(bookmarkUrl.origin, icon) 149 | } else if (icon.startsWith('http://') || icon.startsWith('https://')) { 150 | return icon 151 | } else { 152 | return combineUrl(bookmarkUrl.origin, icon) 153 | } 154 | } 155 | } 156 | 157 | export const getTimeBeforeShow = (waitTime: number) => { 158 | const MINUTE = 60*1000 159 | const HOUR = 60*MINUTE 160 | const DAY = 24*HOUR 161 | const YEAR = 365*DAY 162 | 163 | const year = Math.floor(waitTime/YEAR) 164 | const day = Math.floor((waitTime%YEAR)/DAY) 165 | const hour = Math.floor((waitTime%DAY)/HOUR) 166 | const minute = Math.floor((waitTime%HOUR)/MINUTE) 167 | 168 | if (year > 0) { 169 | return `${year}年前` 170 | } 171 | if (day > 0) { 172 | return `${day}天前` 173 | } 174 | if (hour > 0) { 175 | return `${hour}小时前` 176 | } 177 | if (minute > 0) { 178 | return `${minute}分钟前` 179 | } 180 | 181 | return '刚刚' 182 | } 183 | 184 | export const getTimeAfterShow = (waitTime: number) => { 185 | const MINUTE = 60*1000 186 | const HOUR = 60*MINUTE 187 | const DAY = 24*HOUR 188 | 189 | const day = Math.floor(waitTime/DAY) 190 | const hour = Math.floor((waitTime%DAY)/HOUR) 191 | const minute = Math.floor((waitTime%HOUR)/MINUTE) 192 | 193 | if (day > 0) { 194 | return `${day}天后` 195 | } 196 | if (hour > 0) { 197 | return `${hour}小时后` 198 | } 199 | if (minute > 0) { 200 | return `${minute}分钟后` 201 | } 202 | 203 | return '马上' 204 | } 205 | 206 | /** 207 | * 'yyyy-MM-DD HH:mm:ss' 208 | */ 209 | export const getDateTimeFormat = (data: Date) => { 210 | const year = data.getFullYear() 211 | const month = data.getMonth()+1 212 | const day = data.getDate() 213 | const hour = data.getHours() 214 | const minute = data.getMinutes() 215 | const second = data.getSeconds() 216 | return `${year}-${month}-${day} ${hour}:${minute}:${second}` 217 | } 218 | 219 | /** 220 | * 'yyyy-MM-DD' 221 | */ 222 | export const getDateFormat = (data: Date) => { 223 | const year = data.getFullYear() 224 | const month = data.getMonth()+1 225 | const day = data.getDate() 226 | return `${year}-${month}-${day}` 227 | } 228 | 229 | export const isInRect = (x: number, y: number, rect: DOMRect) => { 230 | return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom 231 | } 232 | 233 | export interface RemovedParamUrl { 234 | newUrl: string 235 | paramValue?: string 236 | paramValues: {[key: string]: string} 237 | } 238 | 239 | /** 240 | * 移除url里的参数 241 | * 如果有paramName,则extraRemoveParamNames里的一起移除 242 | */ 243 | export const removeUrlParams = (url: string, paramName: string, extraRemoveParamNames?: string[]): RemovedParamUrl => { 244 | const params = new URL(url).searchParams 245 | const paramValue = params.get(paramName) 246 | params.delete(paramName) 247 | const paramValues: {[key: string]: string} = {} 248 | if (paramValue) { 249 | for (const paramName_ of extraRemoveParamNames??[]) { 250 | const value = params.get(paramName_) 251 | if (value) { 252 | paramValues[paramName_] = value 253 | } 254 | params.delete(paramName_) 255 | } 256 | } 257 | const newSearch = params.toString() 258 | const base = url.split('?')[0] 259 | const newUrl = base + (newSearch ? ('?' + newSearch) : '') 260 | console.log(`[removeUrlParams]${paramName}: ${paramValue??''} (${url} -> ${newUrl})`) 261 | return { 262 | newUrl, 263 | paramValue: paramValue??undefined, 264 | paramValues, 265 | } 266 | } 267 | 268 | export const getFaviconUrl = (url: string) => { 269 | try { 270 | if (url) { 271 | const urlObj = new URL(url) 272 | return `${urlObj.origin}/favicon.ico` 273 | } 274 | } catch (e) { 275 | } 276 | } 277 | 278 | export const fixUrl = (url: string | undefined, defaultSchema?: string) => { 279 | if (url) { 280 | const urlLower = url.toLowerCase() 281 | if (!urlLower.startsWith('http://') && !urlLower.startsWith('https://') && !urlLower.startsWith('chrome://') && !urlLower.startsWith('edge://')) { 282 | url = (defaultSchema??'http')+'://'+url 283 | } 284 | 285 | if (url) { 286 | try { 287 | const urlObj = new URL(url) 288 | return urlObj.toString() 289 | } catch (e) { 290 | console.error(e) 291 | } 292 | } 293 | } 294 | 295 | return '' 296 | } 297 | 298 | export const getNameFromUrl = (url: string) => { 299 | const urlObj = new URL(url) 300 | return urlObj.hostname 301 | } 302 | 303 | export const orElse = (value: T, defaultValue: T) => { 304 | // string 305 | if (value instanceof String) { 306 | return value.length > 0 ? value : defaultValue 307 | } 308 | return value ?? defaultValue 309 | } 310 | 311 | export const orElses = (...values: T[]) => { 312 | for (const value of values) { 313 | // string 314 | if (value instanceof String) { 315 | if (value.length > 0) { 316 | return value 317 | } else { 318 | continue 319 | } 320 | } 321 | // other 322 | if (value) { 323 | return value 324 | } 325 | } 326 | } 327 | 328 | /** 329 | * 从url中获取参数 330 | */ 331 | export const getQuery = (name: string) => { 332 | const search = window.location.search 333 | const reg = new RegExp('(^|&)' + name + '=([^&]*)(&|$)', 'i') 334 | const r = search.substr(1).match(reg) 335 | if (r != null) { 336 | return decodeURIComponent(r[2]) 337 | } 338 | return null 339 | } 340 | 341 | export const hasStr = (list: string | string[] | undefined | null, str: string) => { 342 | if (list) { 343 | if (typeof list === 'string') { 344 | return list === str 345 | } else { 346 | return list.includes(str) 347 | } 348 | } 349 | return false 350 | } 351 | 352 | /** 353 | * 导出文本内容(按utf-8编码导出) 354 | * @param filename 如'data.json' 355 | */ 356 | export const exportFile = (content: string, filename: string) => { 357 | const element = document.createElement('a') 358 | element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(content)) 359 | element.setAttribute('download', filename) 360 | 361 | element.style.display = 'none' 362 | document.body.appendChild(element) 363 | 364 | element.click() 365 | 366 | document.body.removeChild(element) 367 | } 368 | 369 | export const getUrlExtension = (url: string) => { 370 | return url.split(/[#?]/)[0]?.split('.')?.pop()?.trim() 371 | } 372 | 373 | export const downloadText = (data: string, fileName: string) => { 374 | const blob = new Blob([data]) 375 | // Create an object URL for the blob 376 | const url = URL.createObjectURL(blob) 377 | 378 | // Create an element to use as a link to the image 379 | const a = document.createElement('a') 380 | a.href = url 381 | a.referrerPolicy = 'no-referrer' 382 | a.download = fileName 383 | 384 | a.style.display = 'none' 385 | document.body.appendChild(a) 386 | 387 | a.click() 388 | 389 | document.body.removeChild(a) 390 | } 391 | 392 | export const downloadImage = async (imageUrl: string, fileName?: string) => { 393 | if (!fileName) { 394 | const ext = getUrlExtension(imageUrl) 395 | fileName = `download.${ext??'jpg'}` 396 | } 397 | 398 | const response = await fetch(imageUrl) 399 | const blob = await response.blob() 400 | // Create an object URL for the blob 401 | const url = URL.createObjectURL(blob) 402 | 403 | // Create an element to use as a link to the image 404 | const a = document.createElement('a') 405 | a.href = url 406 | a.referrerPolicy = 'no-referrer' 407 | a.download = '' // Set the file name 408 | 409 | a.style.display = 'none' 410 | document.body.appendChild(a) 411 | 412 | a.click() 413 | 414 | document.body.removeChild(a) 415 | } 416 | 417 | /** 418 | * @returns suffix, e.g. 'png' 419 | */ 420 | export const getSuffix = (filename: string) => { 421 | const pos = filename.lastIndexOf('.') 422 | let suffix = '' 423 | if (pos !== -1) { 424 | suffix = filename.substring(pos + 1) 425 | } 426 | return suffix 427 | } 428 | -------------------------------------------------------------------------------- /src/components/Body.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useEffect, useMemo, useRef } from 'react' 2 | import { 3 | addAskInfo, 4 | mergeAskInfo, 5 | setAutoScroll, 6 | setAutoTranslate, 7 | setCheckAutoScroll, 8 | setFoldAll, 9 | setNeedScroll, 10 | setSearchText, 11 | setSegmentFold, 12 | setTempData 13 | } from '../redux/envReducer' 14 | import { useAppDispatch, useAppSelector } from '../hooks/redux' 15 | import { 16 | AiOutlineAim, 17 | AiOutlineCloseCircle, 18 | FaRegArrowAltCircleDown, 19 | IoWarning, 20 | MdExpand, 21 | RiTranslate 22 | } from 'react-icons/all' 23 | import classNames from 'classnames' 24 | import toast from 'react-hot-toast' 25 | import SegmentCard from './SegmentCard' 26 | import { 27 | ASK_ENABLED_DEFAULT, 28 | DEFAULT_USE_PORT, 29 | HEADER_HEIGHT, 30 | SEARCH_BAR_HEIGHT, 31 | SUMMARIZE_ALL_THRESHOLD, 32 | TITLE_HEIGHT 33 | } from '../consts/const' 34 | import { FaClipboardList } from 'react-icons/fa' 35 | import useTranslate from '../hooks/useTranslate' 36 | import { openUrl } from '../utils/env_util' 37 | import useKeyService from '../hooks/useKeyService' 38 | import Ask from './Ask' 39 | import { v4 } from 'uuid' 40 | import RateExtension from '../components/RateExtension' 41 | import ApiKeyReminder from './ApiKeyReminder' 42 | import { useMessaging } from '../message' 43 | 44 | const Body = () => { 45 | const dispatch = useAppDispatch() 46 | const inputting = useAppSelector(state => state.env.inputting) 47 | const noVideo = useAppSelector(state => state.env.noVideo) 48 | const autoTranslate = useAppSelector(state => state.env.autoTranslate) 49 | const autoScroll = useAppSelector(state => state.env.autoScroll) 50 | const segments = useAppSelector(state => state.env.segments) 51 | const foldAll = useAppSelector(state => state.env.foldAll) 52 | const envData = useAppSelector(state => state.env.envData) 53 | const compact = useAppSelector(state => state.env.tempData.compact) 54 | const floatKeyPointsSegIdx = useAppSelector(state => state.env.floatKeyPointsSegIdx) 55 | const translateEnable = useAppSelector(state => state.env.envData.translateEnable) 56 | const summarizeEnable = useAppSelector(state => state.env.envData.summarizeEnable) 57 | const { addSummarizeTask, addAskTask } = useTranslate() 58 | // const infos = useAppSelector(state => state.env.infos) 59 | const bodyRef = useRef() 60 | const curOffsetTop = useAppSelector(state => state.env.curOffsetTop) 61 | const checkAutoScroll = useAppSelector(state => state.env.checkAutoScroll) 62 | const needScroll = useAppSelector(state => state.env.needScroll) 63 | const totalHeight = useAppSelector(state => state.env.totalHeight) 64 | const curSummaryType = useAppSelector(state => state.env.tempData.curSummaryType) 65 | // const title = useAppSelector(state => state.env.title) 66 | // const fontSize = useAppSelector(state => state.env.envData.fontSize) 67 | const searchText = useAppSelector(state => state.env.searchText) 68 | const asks = useAppSelector(state => state.env.asks) 69 | const { disconnected } = useMessaging(DEFAULT_USE_PORT) 70 | // const recommendIdx = useMemo(() => random(0, 3), []) 71 | const showSearchInput = useMemo(() => { 72 | return (segments != null && segments.length > 0) && (envData.searchEnabled ? envData.searchEnabled : (envData.askEnabled ?? ASK_ENABLED_DEFAULT)) 73 | }, [envData.askEnabled, envData.searchEnabled, segments]) 74 | const searchPlaceholder = useMemo(() => { 75 | let placeholder = '' 76 | if (envData.searchEnabled) { 77 | if (envData.askEnabled ?? ASK_ENABLED_DEFAULT) { 78 | placeholder = '搜索或提问字幕内容(按Enter提问)' 79 | } else { 80 | placeholder = '搜索字幕内容' 81 | } 82 | } else { 83 | if (envData.askEnabled ?? ASK_ENABLED_DEFAULT) { 84 | placeholder = '提问字幕内容' 85 | } 86 | } 87 | return placeholder 88 | }, [envData.askEnabled, envData.searchEnabled]) 89 | 90 | const normalCallback = useCallback(() => { 91 | dispatch(setTempData({ 92 | compact: false 93 | })) 94 | }, [dispatch]) 95 | 96 | const compactCallback = useCallback(() => { 97 | dispatch(setTempData({ 98 | compact: true 99 | })) 100 | }, [dispatch]) 101 | 102 | const posCallback = useCallback(() => { 103 | dispatch(setNeedScroll(true)) 104 | }, [dispatch]) 105 | 106 | const onSummarizeAll = useCallback(() => { 107 | const apiKey = envData.apiKey 108 | if (!apiKey) { 109 | toast.error('请先在选项页面设置ApiKey!') 110 | return 111 | } 112 | const segments_ = [] 113 | for (const segment of segments ?? []) { 114 | const summary = segment.summaries[curSummaryType] 115 | if (!summary || summary.status === 'init' || (summary.status === 'done' && summary.error)) { 116 | segments_.push(segment) 117 | } 118 | } 119 | if (segments_.length === 0) { 120 | toast.error('没有可总结的段落!') 121 | return 122 | } 123 | if (segments_.length < SUMMARIZE_ALL_THRESHOLD || confirm(`确定总结${segments_.length}个段落?`)) { 124 | for (const segment of segments_) { 125 | addSummarizeTask(curSummaryType, segment).catch(console.error) 126 | } 127 | toast.success(`已添加${segments_.length}个总结任务!`) 128 | } 129 | }, [addSummarizeTask, curSummaryType, envData.apiKey, segments]) 130 | 131 | const onFoldAll = useCallback(() => { 132 | dispatch(setFoldAll(!foldAll)) 133 | for (const ask of asks) { 134 | dispatch(mergeAskInfo({ 135 | id: ask.id, 136 | fold: !foldAll 137 | })) 138 | } 139 | for (const segment of segments ?? []) { 140 | dispatch(setSegmentFold({ 141 | segmentStartIdx: segment.startIdx, 142 | fold: !foldAll 143 | })) 144 | } 145 | }, [asks, dispatch, foldAll, segments]) 146 | 147 | const toggleAutoTranslateCallback = useCallback(() => { 148 | const apiKey = envData.apiKey 149 | if (apiKey) { 150 | dispatch(setAutoTranslate(!autoTranslate)) 151 | } else { 152 | toast.error('请先在选项页面设置ApiKey!') 153 | } 154 | }, [autoTranslate, dispatch, envData.apiKey]) 155 | 156 | const onEnableAutoScroll = useCallback(() => { 157 | dispatch(setAutoScroll(true)) 158 | dispatch(setNeedScroll(true)) 159 | }, [dispatch]) 160 | 161 | const onWheel = useCallback(() => { 162 | if (autoScroll) { 163 | dispatch(setAutoScroll(false)) 164 | } 165 | }, [autoScroll, dispatch]) 166 | 167 | // const onCopy = useCallback(() => { 168 | // const [success, content] = getSummarize(title, segments, curSummaryType) 169 | // if (success) { 170 | // navigator.clipboard.writeText(content).then(() => { 171 | // toast.success('复制成功') 172 | // }).catch(console.error) 173 | // } 174 | // }, [curSummaryType, segments, title]) 175 | 176 | const onSearchTextChange = useCallback((e: any) => { 177 | const searchText = e.target.value 178 | dispatch(setSearchText(searchText)) 179 | }, [dispatch]) 180 | 181 | const onClearSearchText = useCallback(() => { 182 | dispatch(setSearchText('')) 183 | }, [dispatch]) 184 | 185 | const onAsk = useCallback(() => { 186 | if ((envData.askEnabled ?? ASK_ENABLED_DEFAULT) && searchText) { 187 | const apiKey = envData.apiKey 188 | if (apiKey) { 189 | if (segments != null && segments.length > 0) { 190 | const id = v4() 191 | addAskTask(id, segments[0], searchText).catch(console.error) 192 | // 添加ask 193 | dispatch(addAskInfo({ 194 | id, 195 | question: searchText, 196 | status: 'pending', 197 | })) 198 | } 199 | } else { 200 | toast.error('请先在选项页面设置ApiKey!') 201 | } 202 | } 203 | }, [addAskTask, dispatch, envData.apiKey, envData.askEnabled, searchText, segments]) 204 | 205 | // service 206 | useKeyService() 207 | 208 | // 自动滚动 209 | useEffect(() => { 210 | if (checkAutoScroll && curOffsetTop && autoScroll && !needScroll) { 211 | if (bodyRef.current.scrollTop <= curOffsetTop - bodyRef.current.offsetTop - (totalHeight - 160) + (floatKeyPointsSegIdx != null ? 100 : 0) || 212 | bodyRef.current.scrollTop >= curOffsetTop - bodyRef.current.offsetTop - 40 - 10 213 | ) { 214 | dispatch(setNeedScroll(true)) 215 | dispatch(setCheckAutoScroll(false)) 216 | console.debug('need scroll') 217 | } 218 | } 219 | }, [autoScroll, checkAutoScroll, curOffsetTop, dispatch, floatKeyPointsSegIdx, needScroll, totalHeight]) 220 | 221 | return
222 | {/* title */} 223 |
224 | 225 | {segments != null && segments.length > 0 && 226 | } 228 |
229 |
237 |
238 | {translateEnable &&
240 | 241 |
} 242 | {summarizeEnable && 243 |
244 | 245 |
} 246 | {noVideo &&
247 | 248 |
} 249 |
250 | 251 | {/* search */} 252 | {showSearchInput &&
253 | { 254 | // enter 255 | if (e.key === 'Enter') { 256 | if (!inputting) { 257 | e.preventDefault() 258 | e.stopPropagation() 259 | onAsk() 260 | dispatch(setSearchText('')) 261 | } 262 | } 263 | }} /> 264 | {searchText && } 265 |
} 266 | 267 | {disconnected &&
268 | 已断开连接 269 |
} 270 | 271 | {/* auto scroll btn */} 272 | {!autoScroll &&
276 | 277 |
} 278 | 279 | {/* body */} 280 |
286 | {/* asks */} 287 | {asks.map(ask => )} 288 | 289 | {/* segments */} 290 | {segments?.map((segment, segmentIdx) => )} 292 | 293 | {/* tip */} 294 |
快捷键提示
295 |
    296 |
  • 单击字幕跳转,双击字幕跳转+切换暂停。
  • 297 |
  • alt+单击字幕复制单条字幕。
  • 298 |
  • 上下方向键来移动当前字幕(可先点击字幕使焦点在字幕列表内)。
  • 299 |
300 | 301 | 302 | 303 | 304 |
305 |
306 | } 307 | 308 | export default Body 309 | -------------------------------------------------------------------------------- /src/inject/inject.ts: -------------------------------------------------------------------------------- 1 | import { TOTAL_HEIGHT_DEF, HEADER_HEIGHT, TOTAL_HEIGHT_MIN, TOTAL_HEIGHT_MAX, IFRAME_ID, STORAGE_ENV, DEFAULT_USE_PORT } from '@/consts/const' 2 | import { AllExtensionMessages, AllInjectMessages, AllAPPMessages } from '@/message-typings' 3 | import { InjectMessaging } from '../message' 4 | 5 | const debug = (...args: any[]) => { 6 | console.debug('[Inject]', ...args) 7 | } 8 | 9 | (async function () { 10 | // 如果路径不是/video或/list,则不注入 11 | if (!location.pathname.startsWith('/video') && !location.pathname.startsWith('/list')) { 12 | debug('Not inject') 13 | return 14 | } 15 | 16 | // 读取envData 17 | const envDataStr = (await chrome.storage.sync.get(STORAGE_ENV))[STORAGE_ENV] 18 | let sidePanel: boolean | null = null 19 | let manualInsert: boolean | null = null 20 | if (envDataStr) { 21 | try { 22 | const envData = JSON.parse(envDataStr) 23 | debug('envData: ', envData) 24 | 25 | sidePanel = envData.sidePanel 26 | manualInsert = envData.manualInsert 27 | } catch (error) { 28 | console.error('Error parsing envData:', error) 29 | } 30 | } 31 | 32 | const runtime: { 33 | injectMessaging: InjectMessaging 34 | // lastV?: string | null 35 | // lastVideoInfo?: VideoInfo 36 | 37 | fold: boolean 38 | 39 | videoElement?: HTMLVideoElement 40 | videoElementHeight: number 41 | 42 | showTrans: boolean 43 | curTrans?: string 44 | } = { 45 | injectMessaging: new InjectMessaging(DEFAULT_USE_PORT), 46 | fold: true, 47 | videoElementHeight: TOTAL_HEIGHT_DEF, 48 | showTrans: false, 49 | } 50 | 51 | const getVideoElement = () => { 52 | const videoWrapper = document.getElementById('bilibili-player') 53 | return videoWrapper?.querySelector('video') as HTMLVideoElement | undefined 54 | } 55 | 56 | /** 57 | * @return if changed 58 | */ 59 | const refreshVideoElement = () => { 60 | const newVideoElement = getVideoElement() 61 | const newVideoElementHeight = (newVideoElement != null) ? (Math.min(Math.max(newVideoElement.offsetHeight, TOTAL_HEIGHT_MIN), TOTAL_HEIGHT_MAX)) : TOTAL_HEIGHT_DEF 62 | if (newVideoElement === runtime.videoElement && Math.abs(newVideoElementHeight - runtime.videoElementHeight) < 1) { 63 | return false 64 | } else { 65 | runtime.videoElement = newVideoElement 66 | runtime.videoElementHeight = newVideoElementHeight 67 | // update iframe height 68 | updateIframeHeight() 69 | return true 70 | } 71 | } 72 | 73 | const createIframe = () => { 74 | var danmukuBox = document.getElementById('danmukuBox') 75 | if (danmukuBox) { 76 | var vKey = '' 77 | for (const key in danmukuBox?.dataset) { 78 | if (key.startsWith('v-')) { 79 | vKey = key 80 | break 81 | } 82 | } 83 | 84 | const iframe = document.createElement('iframe') 85 | iframe.id = IFRAME_ID 86 | iframe.src = chrome.runtime.getURL('index.html') 87 | iframe.style.border = 'none' 88 | iframe.style.width = '100%' 89 | iframe.style.height = '44px' 90 | iframe.style.marginBottom = '3px' 91 | iframe.allow = 'clipboard-read; clipboard-write;' 92 | 93 | if (vKey) { 94 | iframe.dataset[vKey] = danmukuBox?.dataset[vKey] 95 | } 96 | 97 | // insert before first child 98 | danmukuBox?.insertBefore(iframe, danmukuBox?.firstChild) 99 | 100 | // show badge 101 | runtime.injectMessaging.sendExtension('SHOW_FLAG', { 102 | show: true 103 | }) 104 | 105 | debug('iframe inserted') 106 | 107 | return iframe 108 | } 109 | } 110 | 111 | if (!sidePanel && !manualInsert) { 112 | const timerIframe = setInterval(function () { 113 | var danmukuBox = document.getElementById('danmukuBox') 114 | if (danmukuBox) { 115 | clearInterval(timerIframe) 116 | 117 | // 延迟插入iframe(插入太快,网络较差时容易出现b站网页刷新,原因暂时未知,可能b站的某种机制?) 118 | setTimeout(createIframe, 1500) 119 | } 120 | }, 1000) 121 | } 122 | 123 | let aid: number | null = null 124 | let ctime: number | null = null 125 | let author: string | undefined 126 | let title = '' 127 | let pages: any[] = [] 128 | let pagesMap: Record = {} 129 | 130 | let lastAidOrBvid: string | null = null 131 | const refreshVideoInfo = async (force: boolean = false) => { 132 | if (force) { 133 | lastAidOrBvid = null 134 | } 135 | if (!sidePanel) { 136 | const iframe = document.getElementById(IFRAME_ID) as HTMLIFrameElement | undefined 137 | if (!iframe) return 138 | } 139 | 140 | // fix: https://github.com/IndieKKY/bilibili-subtitle/issues/5 141 | // 处理稍后再看的url( https://www.bilibili.com/list/watchlater?bvid=xxx&oid=xxx ) 142 | const pathSearchs: Record = {} 143 | // eslint-disable-next-line no-return-assign 144 | location.search.slice(1).replace(/([^=&]*)=([^=&]*)/g, (matchs, a, b, c) => pathSearchs[a] = b) 145 | 146 | // bvid 147 | let aidOrBvid = pathSearchs.bvid // 默认为稍后再看 148 | if (!aidOrBvid) { 149 | let path = location.pathname 150 | if (path.endsWith('/')) { 151 | path = path.slice(0, -1) 152 | } 153 | const paths = path.split('/') 154 | aidOrBvid = paths[paths.length - 1] 155 | } 156 | 157 | if (aidOrBvid !== lastAidOrBvid) { 158 | // console.debug('refreshVideoInfo') 159 | 160 | lastAidOrBvid = aidOrBvid 161 | if (aidOrBvid) { 162 | // aid,pages 163 | let cid: string | undefined 164 | /** 165 | * [ 166 | { 167 | "type": 2, 168 | "from": 0, 169 | "to": 152, //单位秒 170 | "content": "发现美", 171 | "imgUrl": "http://i0.hdslb.com/bfs/vchapter/29168372111_0.jpg", 172 | "logoUrl": "", 173 | "team_type": "", 174 | "team_name": "" 175 | } 176 | ] 177 | */ 178 | let chapters: any[] = [] 179 | let subtitles 180 | if (aidOrBvid.toLowerCase().startsWith('av')) { // avxxx 181 | aid = parseInt(aidOrBvid.slice(2)) 182 | pages = await fetch(`https://api.bilibili.com/x/player/pagelist?aid=${aid}`, { credentials: 'include' }).then(async res => await res.json()).then(res => res.data) 183 | cid = pages[0].cid 184 | ctime = pages[0].ctime 185 | author = pages[0].owner?.name 186 | title = pages[0].part 187 | await fetch(`https://api.bilibili.com/x/player/wbi/v2?aid=${aid}&cid=${cid!}`, { credentials: 'include' }).then(async res => await res.json()).then(res => { 188 | chapters = res.data.view_points ?? [] 189 | subtitles = res.data.subtitle.subtitles 190 | }) 191 | } else { // bvxxx 192 | await fetch(`https://api.bilibili.com/x/web-interface/view?bvid=${aidOrBvid}`, { credentials: 'include' }).then(async res => await res.json()).then(async res => { 193 | title = res.data.title 194 | aid = res.data.aid 195 | cid = res.data.cid 196 | ctime = res.data.ctime 197 | author = res.data.owner?.name 198 | pages = res.data.pages 199 | }) 200 | await fetch(`https://api.bilibili.com/x/player/wbi/v2?aid=${aid!}&cid=${cid!}`, { credentials: 'include' }).then(async res => await res.json()).then(res => { 201 | chapters = res.data.view_points ?? [] 202 | subtitles = res.data.subtitle.subtitles 203 | }) 204 | } 205 | 206 | // 筛选chapters里type为2的 207 | chapters = chapters.filter(chapter => chapter.type === 2) 208 | 209 | // pagesMap 210 | pagesMap = {} 211 | pages.forEach(page => { 212 | pagesMap[page.page + ''] = page 213 | }) 214 | 215 | debug('refreshVideoInfo: ', aid, cid, pages, subtitles) 216 | 217 | // send setVideoInfo 218 | runtime.injectMessaging.sendApp(!!sidePanel, 'SET_VIDEO_INFO', { 219 | url: location.origin + location.pathname, 220 | title, 221 | aid, 222 | ctime, 223 | author, 224 | pages, 225 | chapters, 226 | infos: subtitles, 227 | }) 228 | } 229 | } 230 | } 231 | 232 | let lastAid: number | null = null 233 | let lastCid: number | null = null 234 | const refreshSubtitles = () => { 235 | if (!sidePanel) { 236 | const iframe = document.getElementById(IFRAME_ID) as HTMLIFrameElement | undefined 237 | if (!iframe) return 238 | } 239 | 240 | const urlSearchParams = new URLSearchParams(window.location.search) 241 | const p = urlSearchParams.get('p') || 1 242 | const page = pagesMap[p] 243 | if (!page) return 244 | const cid: number | null = page.cid 245 | 246 | if (aid !== lastAid || cid !== lastCid) { 247 | debug('refreshSubtitles', aid, cid) 248 | 249 | lastAid = aid 250 | lastCid = cid 251 | if (aid && cid) { 252 | fetch(`https://api.bilibili.com/x/player/wbi/v2?aid=${aid}&cid=${cid}`, { 253 | credentials: 'include', 254 | }) 255 | .then(async res => await res.json()) 256 | .then(res => { 257 | // remove elements with empty subtitle_url 258 | res.data.subtitle.subtitles = res.data.subtitle.subtitles.filter((item: any) => item.subtitle_url) 259 | if (res.data.subtitle.subtitles.length > 0) { 260 | runtime.injectMessaging.sendApp(!!sidePanel, 'SET_INFOS', { 261 | infos: res.data.subtitle.subtitles 262 | }) 263 | } 264 | }) 265 | } 266 | } 267 | } 268 | 269 | const updateIframeHeight = () => { 270 | const iframe = document.getElementById(IFRAME_ID) as HTMLIFrameElement | undefined 271 | if (iframe != null) { 272 | iframe.style.height = (runtime.fold ? HEADER_HEIGHT : runtime.videoElementHeight) + 'px' 273 | } 274 | } 275 | 276 | const methods: { 277 | [K in AllInjectMessages['method']]: (params: Extract['params'], context: MethodContext) => Promise 278 | } = { 279 | TOGGLE_DISPLAY: async (params) => { 280 | const iframe = document.getElementById(IFRAME_ID) as HTMLIFrameElement | undefined 281 | if (iframe != null) { 282 | iframe.style.display = iframe.style.display === 'none' ? 'block' : 'none' 283 | runtime.injectMessaging.sendExtension('SHOW_FLAG', { 284 | show: iframe.style.display !== 'none' 285 | }) 286 | } else { 287 | createIframe() 288 | } 289 | }, 290 | FOLD: async (params) => { 291 | runtime.fold = params.fold 292 | updateIframeHeight() 293 | }, 294 | MOVE: async (params) => { 295 | const video = getVideoElement() 296 | if (video != null) { 297 | video.currentTime = params.time 298 | if (params.togglePause) { 299 | video.paused ? video.play() : video.pause() 300 | } 301 | } 302 | }, 303 | GET_SUBTITLE: async (params) => { 304 | let url = params.info.subtitle_url 305 | if (url.startsWith('http://')) { 306 | url = url.replace('http://', 'https://') 307 | } 308 | return await fetch(url).then(async res => await res.json()) 309 | }, 310 | GET_VIDEO_STATUS: async (params) => { 311 | const video = getVideoElement() 312 | if (video != null) { 313 | return { 314 | paused: video.paused, 315 | currentTime: video.currentTime 316 | } 317 | } 318 | }, 319 | GET_VIDEO_ELEMENT_INFO: async (params) => { 320 | refreshVideoElement() 321 | return { 322 | noVideo: runtime.videoElement == null, 323 | totalHeight: runtime.videoElementHeight, 324 | } 325 | }, 326 | REFRESH_VIDEO_INFO: async (params) => { 327 | refreshVideoInfo(params.force) 328 | }, 329 | UPDATE_TRANS_RESULT: async (params) => { 330 | runtime.showTrans = true 331 | runtime.curTrans = params?.result 332 | 333 | let text = document.getElementById('trans-result-text') 334 | if (text) { 335 | text.innerHTML = runtime.curTrans ?? '' 336 | } else { 337 | const container = document.getElementsByClassName('bpx-player-subtitle-panel-wrap')?.[0] 338 | if (container) { 339 | const div = document.createElement('div') 340 | div.style.display = 'flex' 341 | div.style.justifyContent = 'center' 342 | div.style.margin = '2px' 343 | text = document.createElement('text') 344 | text.id = 'trans-result-text' 345 | text.innerHTML = runtime.curTrans ?? '' 346 | text.style.fontSize = '1rem' 347 | text.style.padding = '5px' 348 | text.style.color = 'white' 349 | text.style.background = 'rgba(0, 0, 0, 0.4)' 350 | div.append(text) 351 | 352 | container.append(div) 353 | } 354 | } 355 | text && (text.style.display = runtime.curTrans ? 'block' : 'none') 356 | }, 357 | HIDE_TRANS: async (params) => { 358 | runtime.showTrans = false 359 | runtime.curTrans = undefined 360 | 361 | const text = document.getElementById('trans-result-text') 362 | if (text) { 363 | text.style.display = 'none' 364 | } 365 | }, 366 | PLAY: async (params) => { 367 | const { play } = params 368 | const video = getVideoElement() 369 | if (video != null) { 370 | if (play) { 371 | await video.play() 372 | } else { 373 | video.pause() 374 | } 375 | } 376 | }, 377 | DOWNLOAD_AUDIO: async (params) => { 378 | const html = document.getElementsByTagName('html')[0].innerHTML 379 | const playInfo = JSON.parse(html.match(/window.__playinfo__=(.+?)<\/script/)?.[1] ?? '{}') 380 | const audioUrl = playInfo.data.dash.audio[0].baseUrl 381 | 382 | fetch(audioUrl).then(async res => await res.blob()).then(blob => { 383 | const a = document.createElement('a') 384 | a.href = URL.createObjectURL(blob) 385 | a.download = `${title}.m4s` 386 | a.click() 387 | }) 388 | }, 389 | } 390 | 391 | // 初始化injectMessage 392 | runtime.injectMessaging.init(methods) 393 | 394 | setInterval(() => { 395 | if (!sidePanel) { 396 | const iframe = document.getElementById(IFRAME_ID) as HTMLIFrameElement | undefined 397 | if (!iframe || iframe.style.display === 'none') return 398 | } 399 | 400 | refreshVideoInfo().catch(console.error) 401 | refreshSubtitles() 402 | }, 1000) 403 | })() 404 | --------------------------------------------------------------------------------