This commit is contained in:
IndieKKY
2023-05-17 16:37:56 +08:00
commit 858f83a45c
59 changed files with 8855 additions and 0 deletions

17
.editorconfig Normal file
View File

@@ -0,0 +1,17 @@
# http://editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
indent_size = 3
[Makefile]
indent_style = tab

1
.env.development Normal file
View File

@@ -0,0 +1 @@
VITE_ENV=web-dev

3
.env.production_chrome Normal file
View File

@@ -0,0 +1,3 @@
NODE_ENV=production
VITE_ENV=chrome

6
.eslintignore Normal file
View File

@@ -0,0 +1,6 @@
public/
dist/
node_modules/
postcss.config.cjs
vite.config.ts
vite-env.d.ts

40
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,40 @@
module.exports = {
"env": {
"browser": true,
"es2021": true
},
"extends": [
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"standard-with-typescript",
],
"overrides": [],
"parserOptions": {
"project": "tsconfig.json",
"ecmaVersion": "latest",
"sourceType": "module"
},
"plugins": [
"react"
],
"rules": {
"react-hooks/exhaustive-deps": "error",
"@typescript-eslint/explicit-function-return-type": "off",
"react/react-in-jsx-scope": "off",
"@typescript-eslint/restrict-plus-operands": "off",
"@typescript-eslint/no-empty-interface": "warn",
"@typescript-eslint/no-unused-vars": "warn",
"@typescript-eslint/strict-boolean-expressions": "warn",
"@typescript-eslint/object-curly-spacing": "off",
"@typescript-eslint/no-misused-promises": "off",
"@typescript-eslint/space-infix-ops": "off",
"operator-linebreak": "off",
"@typescript-eslint/space-before-function-paren": "off",
"@typescript-eslint/comma-dangle": "off"
},
"settings": {
"react": {
"version": "detect"
}
}
}

27
.gitignore vendored Normal file
View File

@@ -0,0 +1,27 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.pnpm-store
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
stats.html

1
.npmrc Normal file
View File

@@ -0,0 +1 @@
registry=https://registry.npmmirror.com/

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright 2023 IndieKKY
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

63
README.md Normal file
View File

@@ -0,0 +1,63 @@
## 简介
哔哩哔哩字幕列表是一个浏览器扩展,旨在提供更高效和可控的视频信息获取方式。
该扩展会显示视频的字幕列表,让用户能够快速浏览字幕内容,并通过点击跳转到相应的视频位置。同时,用户还可以方便地下载字幕文件。
除此之外,该扩展还提供了视频字幕总结功能,帮助用户快速掌握视频的要点。
该扩展主要面向知识学习类的视频,帮助用户更好地理解和总结视频内容。
## 功能特点
- 显示视频的字幕列表
- 点击字幕跳转视频对应位置
- 多种格式复制与下载字幕
- 多种方式总结字幕
- 翻译字幕
## 下载扩展
[chrome商店](https://chrome.google.com/webstore/detail/bciglihaegkdhoogebcdblfhppoilclp)
## 使用说明
安装扩展后,在哔哩哔哩网站观看视频时,视频右侧会显示字幕列表面板。
## 交流联系
QQ群194536885
微信公众号IndieKKY
twitter[https://twitter.com/IndieKky](https://twitter.com/IndieKky)
github: [IndieKKY](https://github.com/IndieKKY)
## 问题反馈
如果您在使用过程中遇到任何问题,或者有任何改进建议,请在项目的 **Issue 页面** 中提出。我们欢迎您的反馈,并会尽快回复和处理相关问题。
## 开发指南
注意!此项目当前代码结构与代码质量不是非常高。
node版本18.15.0
包管理器pnpm
本地开发时,`pnpm run dev`可以开启本地调试,但只能调试部分功能;
`pnpm run build_chrome`可以构建项目,然后浏览器扩展中加载`dist`目录即可,此方式可以调试完整功能,但不是很方便,从改代码到构建完看到效果耗时比较长(取决于你的电脑性能)。
## 贡献指南
欢迎贡献代码或提出改进建议!如果您希望为该项目做出贡献,请遵循以下步骤:
1. Fork该仓库到您自己的账号。
2. 创建您的分支并进行修改。
3. 提交修改前,请确保您的代码通过了所有的测试,并保持良好的代码风格。
4. 提交 Pull Request描述清楚您的修改内容和目的。
5. 我们将仔细审查您的贡献,并与您进行讨论和反馈。
6. 一旦您的贡献被接受并合并到主分支,您的修改将成为项目的一部分。
## 许可证
该项目采用 **MIT 许可证**,详情请参阅许可证文件。

11
fixChrome.cjs Normal file
View File

@@ -0,0 +1,11 @@
console.log('fixChrome.js loaded');
const fs = require('fs')
const manifest = require('./dist/manifest.json')
manifest.web_accessible_resources[0].resources.push('index.html')
manifest.action.default_popup = 'popup.html'
//写回文件
fs.writeFileSync('./dist/manifest.json', JSON.stringify(manifest, null, 2))
console.log('fixChrome.js done');

16
index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="zh" data-theme="light">
<head>
<title>bilibili-subtitle - 哔哩哔哩字幕</title>
<meta charset="UTF-8" />
<meta http-equiv="Content-Type" content="text/html;charset=utf-8">
<link rel="icon" href="/favicon-128x128.png" sizes="128x128">
<link rel="manifest" href="/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="哔哩哔哩字幕" />
<meta name="keywords" content="哔哩哔哩,b站,字幕" />
</head>
<body>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

33
manifest.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "哔哩哔哩字幕列表",
"description": "显示B站视频的字幕列表,可点击跳转与下载字幕,并支持翻译和总结字幕!",
"version": "1.6.5",
"manifest_version": 3,
"permissions": [
"storage"
],
"background": {
"service_worker": "src/chrome/background.ts"
},
"content_scripts": [
{
"matches": ["https://www.bilibili.com/video/*"],
"js": ["src/chrome/content-script.cjs"]
}
],
"icons": {
"16": "favicon-16x16.png",
"32": "favicon-32x32.png",
"48": "favicon-48x48.png",
"128": "favicon-128x128.png"
},
"action": {
"default_popup": "index.html",
"default_icon": {
"16": "favicon-16x16.png",
"32": "favicon-32x32.png",
"48": "favicon-48x48.png",
"128": "favicon-128x128.png"
}
}
}

77
package.json Normal file
View File

@@ -0,0 +1,77 @@
{
"private": true,
"name": "bilibili-subtitle",
"version": "1.6.5",
"type": "module",
"description": "哔哩哔哩字幕列表",
"main": "index.js",
"scripts": {
"dev": "vite",
"build_chrome": "tsc && vite build -m production_chrome && pnpm run fixChrome",
"fix": "eslint --fix --quiet .",
"fixChrome": "node fixChrome.cjs"
},
"author": "IndieKKY",
"license": "MIT",
"dependencies": {
"@crxjs/vite-plugin": "^1.0.14",
"@kky002/kky-hooks": "^1.2.1",
"@kky002/kky-ui": "^1.0.9",
"@kky002/kky-util": "^1.4.2",
"@logto/react": "1.0.0-beta.13",
"@popperjs/core": "^2.11.6",
"@reduxjs/toolkit": "^1.8.5",
"@tippyjs/react": "^4.2.6",
"ahooks": "^3.7.1",
"classnames": "^2.3.2",
"daisyui": "^2.42.1",
"js-search": "^2.0.0",
"less": "^4.1.3",
"lodash-es": "^4.17.21",
"pako": "^2.1.0",
"qs": "^6.11.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.0",
"react-icons": "^4.4.0",
"react-markdown": "^8.0.3",
"react-popper": "^2.3.0",
"react-redux": "^8.0.2",
"react-slider": "^2.0.4",
"remark-gfm": "^3.0.1",
"tailwind-scrollbar-hide": "^1.1.7",
"tiny-pinyin": "^1.3.2",
"tippy.js": "^6.3.7",
"uuid": "^9.0.0"
},
"devDependencies": {
"@tailwindcss/line-clamp": "^0.4.2",
"@tailwindcss/typography": "^0.5.8",
"@types/chrome": "^0.0.203",
"@types/js-search": "^1.4.0",
"@types/lodash-es": "^4.17.6",
"@types/pako": "^2.0.0",
"@types/qs": "^6.9.7",
"@types/react": "^18.0.20",
"@types/react-dom": "^18.0.6",
"@types/react-slider": "^1.3.1",
"@types/uuid": "^8.3.4",
"@typescript-eslint/eslint-plugin": "^5.37.0",
"@typescript-eslint/parser": "^5.37.0",
"@vitejs/plugin-react": "^2.1.0",
"autoprefixer": "^10.4.13",
"eslint": "8.22.0",
"eslint-config-standard": "^17.0.0",
"eslint-config-standard-with-typescript": "^23.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-n": "^15.2.5",
"eslint-plugin-promise": "^6.0.1",
"eslint-plugin-react": "^7.31.8",
"eslint-plugin-react-hooks": "^4.6.0",
"postcss": "^8.4.19",
"rollup-plugin-visualizer": "^5.8.3",
"tailwindcss": "^3.2.4",
"typescript": "^4.8.3",
"vite": "^3.1.1"
}
}

4354
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
postcss.config.cjs Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
}
}

BIN
public/favicon-128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

BIN
public/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 B

BIN
public/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 363 B

BIN
public/favicon-48x48.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 B

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 447 B

11
public/popup.html Normal file
View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="zh" style="width: 500px;">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Type" content="text/html;charset=utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<img src="shot.jpg" style="width: 100%; object-fit: contain;"/>
</body>
</html>

BIN
public/shot.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

3
public/subtitle.svg Normal file
View File

@@ -0,0 +1,3 @@
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg t="1672795365953" class="icon" viewBox="0 0 1303 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2585"
width="254.4921875" height="200"><path d="M110.964364 1023.720727c-60.509091 0-109.847273-48.872727-109.847273-108.916363V109.009455C1.117091 48.965818 50.455273 0 110.964364 0h1081.902545c60.509091 0 109.847273 48.872727 109.847273 108.916364v805.794909c0 60.043636-49.338182 108.916364-109.847273 108.916363H110.964364z m0-927.650909c-6.609455 0-12.101818 5.771636-12.101819 12.939637v805.794909c0 7.168 5.492364 13.032727 12.101819 13.032727h1081.902545c6.609455 0 12.101818-5.864727 12.101818-13.032727V109.009455c0-7.168-5.492364-13.032727-12.101818-13.032728H110.964364z" fill="#00AEEC" p-id="2586"></path><path d="M1003.054545 520.098909a51.572364 51.572364 0 0 1-50.26909 52.689455h-80.058182a51.572364 51.572364 0 0 1-50.269091-52.689455c0-29.137455 22.434909-52.782545 50.269091-52.782545h80.058182c27.834182 0 50.269091 23.645091 50.26909 52.782545zM1096.610909 270.149818c0 28.858182-22.341818 52.410182-49.989818 52.410182H232.541091a51.293091 51.293091 0 0 1-50.082909-52.410182c0-29.044364 22.434909-52.503273 50.082909-52.503273h814.08c27.648 0 50.082909 23.458909 50.082909 52.503273zM768.744727 520.098909c0 28.858182-22.341818 52.410182-50.082909 52.410182H232.541091a51.293091 51.293091 0 0 1-50.082909-52.410182c0-29.044364 22.434909-52.503273 50.082909-52.503273H718.661818c27.648 0 50.082909 23.458909 50.082909 52.503273zM1096.610909 735.976727c0 29.044364-22.341818 52.503273-49.989818 52.503273H560.500364a51.293091 51.293091 0 0 1-50.082909-52.503273c0-28.951273 22.341818-52.410182 50.082909-52.410182h486.120727c27.648 0 50.082909 23.458909 50.082909 52.410182z" fill="#00AEEC" p-id="2587"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

73
src/App.tsx Normal file
View File

@@ -0,0 +1,73 @@
import React, {useCallback, useContext, useEffect, useMemo} from 'react'
import 'tippy.js/dist/tippy.css'
import {useAppDispatch, useAppSelector} from './hooks/redux'
import {setEnvData, setEnvReady, setFold, setPage} from './redux/envReducer'
import Header from './biz/Header'
import Body from './biz/Body'
import useSubtitleService from './hooks/useSubtitleService'
import {cloneDeep} from 'lodash-es'
import {EVENT_EXPAND, PAGE_MAIN, PAGE_SETTINGS, STORAGE_ENV} from './const'
import {EventBusContext} from './Router'
import useTranslateService from './hooks/useTranslateService'
import Settings from './biz/Settings'
import classNames from 'classnames'
import {handleJson} from '@kky002/kky-util'
import {useLocalStorage} from '@kky002/kky-hooks'
import {Toaster} from 'react-hot-toast'
import {setTheme} from './util/biz_util'
function App() {
const dispatch = useAppDispatch()
const envData = useAppSelector(state => state.env.envData)
const fold = useAppSelector(state => state.env.fold)
const eventBus = useContext(EventBusContext)
const page = useAppSelector(state => state.env.page)
const totalHeight = useAppSelector(state => state.env.totalHeight)
const foldCallback = useCallback(() => {
dispatch(setFold(!fold))
dispatch(setPage(PAGE_MAIN))
window.parent.postMessage({type: 'fold', fold: !fold}, '*')
}, [dispatch, fold])
// handle event
eventBus.useSubscription((event: any) => {
if (event.type === EVENT_EXPAND) {
if (fold) {
foldCallback()
}
}
})
// env数据
const savedEnvData = useMemo(() => {
return handleJson(cloneDeep(envData)) as EnvData
}, [envData])
const onLoadEnv = useCallback((data?: EnvData) => {
if (data != null) {
dispatch(setEnvData(data))
}
dispatch(setEnvReady())
}, [dispatch])
useLocalStorage<EnvData>('chrome_client', STORAGE_ENV, savedEnvData, onLoadEnv)
// theme改变时设置主题
useEffect(() => {
setTheme(envData.theme)
}, [envData.theme])
// services
useSubtitleService()
useTranslateService()
return <div className={classNames('select-none', import.meta.env.VITE_ENV === 'web-dev'?'w-[350px]':'w-full')} style={{
height: fold?undefined:`${totalHeight}px`,
}}>
<Header foldCallback={foldCallback}/>
{!fold && page === PAGE_MAIN && <Body/>}
{!fold && page === PAGE_SETTINGS && <Settings/>}
<Toaster position='bottom-center'/>
</div>
}
export default App

26
src/Router.tsx Normal file
View File

@@ -0,0 +1,26 @@
import App from './App'
import {useEventEmitter} from 'ahooks'
import React from 'react'
export const EventBusContext = React.createContext<any>(null)
const map: { [key: string]: string } = {
// '/close': 'close',
}
const Router = () => {
const path = map[window.location.pathname] ?? 'app'
if (path === 'close') {
window.close()
}
// 事件总线
const eventBus = useEventEmitter()
return <EventBusContext.Provider value={eventBus}>
{path === 'app' && <App/>}
</EventBusContext.Provider>
}
export default Router

168
src/biz/Body.tsx Normal file
View File

@@ -0,0 +1,168 @@
import React, {useCallback, useEffect, useRef} from 'react'
import {
setAutoScroll,
setAutoTranslate,
setCheckAutoScroll,
setCompact,
setFoldAll,
setNeedScroll,
setPage,
setSegmentFold
} from '../redux/envReducer'
import {useAppDispatch, useAppSelector} from '../hooks/redux'
import {AiOutlineAim, FaRegArrowAltCircleDown, IoWarning, MdExpand, RiTranslate} from 'react-icons/all'
import classNames from 'classnames'
import toast from 'react-hot-toast'
import SegmentCard from './SegmentCard'
import {HEADER_HEIGHT, PAGE_SETTINGS, SUMMARIZE_ALL_THRESHOLD, TITLE_HEIGHT} from '../const'
import {FaClipboardList} from 'react-icons/fa'
import useTranslate from '../hooks/useTranslate'
const Body = () => {
const dispatch = useAppDispatch()
const noVideo = useAppSelector(state => state.env.noVideo)
const autoTranslate = useAppSelector(state => state.env.autoTranslate)
const autoScroll = useAppSelector(state => state.env.autoScroll)
const segments = useAppSelector(state => state.env.segments)
const foldAll = useAppSelector(state => state.env.foldAll)
const envData = useAppSelector(state => state.env.envData)
const compact = useAppSelector(state => state.env.compact)
const apiKey = useAppSelector(state => state.env.envData.apiKey)
const floatKeyPointsSegIdx = useAppSelector(state => state.env.floatKeyPointsSegIdx)
const translateEnable = useAppSelector(state => state.env.envData.translateEnable)
const summarizeEnable = useAppSelector(state => state.env.envData.summarizeEnable)
const title = useAppSelector(state => state.env.title)
const {addSummarizeTask} = useTranslate()
const bodyRef = useRef<any>()
const curOffsetTop = useAppSelector(state => state.env.curOffsetTop)
const checkAutoScroll = useAppSelector(state => state.env.checkAutoScroll)
const needScroll = useAppSelector(state => state.env.needScroll)
const totalHeight = useAppSelector(state => state.env.totalHeight)
const curSummaryType = useAppSelector(state => state.env.curSummaryType)
const normalCallback = useCallback(() => {
dispatch(setCompact(false))
}, [dispatch])
const compactCallback = useCallback(() => {
dispatch(setCompact(true))
}, [dispatch])
const posCallback = useCallback(() => {
dispatch(setNeedScroll(true))
}, [dispatch])
const onSummarizeAll = useCallback(() => {
if (!apiKey) {
dispatch(setPage(PAGE_SETTINGS))
toast.error('需要先设置ApiKey!')
return
}
const segments_ = []
for (const segment of segments ?? []) {
const summary = segment.summaries[curSummaryType]
if (!summary || summary.status === 'init' || (summary.status === 'done' && summary.error)) {
segments_.push(segment)
}
}
if (segments_.length === 0) {
toast.error('没有可总结的段落!')
return
}
if (segments_.length < SUMMARIZE_ALL_THRESHOLD || confirm(`确定总结${segments_.length}个段落?`)) {
for (const segment of segments_) {
addSummarizeTask(title, curSummaryType, segment).catch(console.error)
}
toast.success(`已添加${segments_.length}个总结任务!`)
}
}, [addSummarizeTask, apiKey, curSummaryType, dispatch, segments, title])
const onFoldAll = useCallback(() => {
dispatch(setFoldAll(!foldAll))
for (const segment of segments ?? []) {
dispatch(setSegmentFold({
segmentStartIdx: segment.startIdx,
fold: !foldAll
}))
}
}, [dispatch, foldAll, segments])
const toggleAutoTranslateCallback = useCallback(() => {
if (envData.apiKey) {
dispatch(setAutoTranslate(!autoTranslate))
} else {
dispatch(setPage(PAGE_SETTINGS))
toast.error('需要先设置ApiKey!')
}
}, [autoTranslate, dispatch, envData.apiKey])
const onEnableAutoScroll = useCallback(() => {
dispatch(setAutoScroll(true))
dispatch(setNeedScroll(true))
}, [dispatch])
const onWheel = useCallback(() => {
if (autoScroll) {
dispatch(setAutoScroll(false))
}
}, [autoScroll, dispatch])
// 自动滚动
useEffect(() => {
if (checkAutoScroll && curOffsetTop && autoScroll && !needScroll) {
if (bodyRef.current.scrollTop <= curOffsetTop - bodyRef.current.offsetTop - (totalHeight-120) + (floatKeyPointsSegIdx != null ? 100 : 0) ||
bodyRef.current.scrollTop >= curOffsetTop - bodyRef.current.offsetTop - 40 - 10
) {
dispatch(setNeedScroll(true))
dispatch(setCheckAutoScroll(false))
console.debug('need scroll')
}
}
}, [autoScroll, checkAutoScroll, curOffsetTop, dispatch, floatKeyPointsSegIdx, needScroll, totalHeight])
return <div className='relative'>
<div className='absolute top-1 left-6 flex-center gap-1'>
<AiOutlineAim className='cursor-pointer' onClick={posCallback} title='滚动到视频位置'/>
{segments != null && segments.length > 1 &&
<MdExpand className={classNames('cursor-pointer', foldAll ? 'text-accent' : '')} onClick={onFoldAll}
title='展开/折叠全部'/>}
</div>
<div className='flex justify-center'>
<div className='tabs'>
<a className={classNames('tab tab-sm tab-bordered', !compact && 'tab-active')}
onClick={normalCallback}></a>
<a className={classNames('tab tab-sm tab-bordered', compact && 'tab-active')}
onClick={compactCallback}></a>
</div>
</div>
<div className='absolute top-1 right-6'>
{translateEnable && <div className='tooltip tooltip-left cursor-pointer' data-tip='点击切换自动翻译'
onClick={toggleAutoTranslateCallback}>
<RiTranslate className={autoTranslate ? 'text-accent' : ''}/>
</div>}
{summarizeEnable &&
<div className='tooltip tooltip-left cursor-pointer z-[100] ml-2' data-tip='总结全部' onClick={onSummarizeAll}>
<FaClipboardList/>
</div>}
{noVideo && <div className='tooltip tooltip-left ml-2' data-tip='当前浏览器不支持视频跳转'>
<IoWarning className='text-warning'/>
</div>}
</div>
{!autoScroll && <div
className='absolute z-[999] top-[96px] right-6 tooltip tooltip-left cursor-pointer rounded-full bg-primary/25 hover:bg-primary/75 text-primary-content p-1.5 text-xl'
data-tip='开启自动滚动'
onClick={onEnableAutoScroll}>
<FaRegArrowAltCircleDown className={autoScroll ? 'text-accent' : ''}/>
</div>}
<div ref={bodyRef} onWheel={onWheel}
className={classNames('flex flex-col gap-1.5 overflow-y-auto select-text scroll-smooth', floatKeyPointsSegIdx != null && 'pb-[100px]')}
style={{
height: `${totalHeight - HEADER_HEIGHT - TITLE_HEIGHT}px`
}}
>
{segments?.map((segment, segmentIdx) => <SegmentCard key={segment.startIdx} segment={segment} segmentIdx={segmentIdx} bodyRef={bodyRef}/>)}
</div>
</div>
}
export default Body

View File

@@ -0,0 +1,33 @@
import React, {useMemo} from 'react'
import {useAppSelector} from '../hooks/redux'
import {getDisplay, getTransText} from '../util/biz_util'
import classNames from 'classnames'
const CompactSegmentItem = (props: {
item: {
from: number
to: number
content: string
}
idx: number
isIn: boolean
last: boolean
moveCallback: (event: any) => void
}) => {
const {item, idx, last, isIn, moveCallback} = props
const transResult = useAppSelector(state => state.env.transResults[idx])
const envData = useAppSelector(state => state.env.envData)
const fontSize = useAppSelector(state => state.env.envData.fontSize)
const autoTranslate = useAppSelector(state => state.env.autoTranslate)
const transText = useMemo(() => getTransText(transResult, envData.hideOnDisableAutoTranslate, autoTranslate), [autoTranslate, envData.hideOnDisableAutoTranslate, transResult])
const display = useMemo(() => getDisplay(envData.transDisplay, item.content, transText), [envData.transDisplay, item.content, transText])
return <div className={classNames('inline', fontSize === 'large'?'text-sm':'text-xs')}>
<span className={'pl-1 pr-0.5 py-0.5 cursor-pointer rounded-sm hover:bg-base-200'} onClick={moveCallback}>
<text className={classNames('font-medium', isIn ? 'text-primary underline' : '')}>{display.main}</text>
{display.sub && <text className='desc'>({display.sub})</text>}</span>
<span>{!last && ', '}</span>
</div>
}
export default CompactSegmentItem

99
src/biz/Header.tsx Normal file
View File

@@ -0,0 +1,99 @@
import {IoIosArrowUp} from 'react-icons/all'
import {useCallback} from 'react'
import {useAppDispatch, useAppSelector} from '../hooks/redux'
import {find, remove} from 'lodash-es'
import {setCurFetched, setCurInfo, setData, setInfos, setUploadedTranscript} from '../redux/envReducer'
import MoreBtn from './MoreBtn'
import classNames from 'classnames'
import {parseTranscript} from '../util/biz_util'
const Header = (props: {
foldCallback: () => void
}) => {
const {foldCallback} = props
const dispatch = useAppDispatch()
const infos = useAppSelector(state => state.env.infos)
const curInfo = useAppSelector(state => state.env.curInfo)
const fold = useAppSelector(state => state.env.fold)
const uploadedTranscript = useAppSelector(state => state.env.uploadedTranscript)
const upload = useCallback(() => {
const input = document.createElement('input')
input.type = 'file'
input.accept = '.vtt,.srt'
input.onchange = (e: any) => {
const file = e.target.files[0]
const reader = new FileReader()
reader.onload = (e) => {
const text = e.target?.result
if (text) {
const infos_ = [...(infos??[])]
// const blob = new Blob([text], {type: 'text/plain'})
// const url = URL.createObjectURL(blob)
// remove old if exist
remove(infos_, {id: 'uploaded'})
// add new
const tarInfo = {id: 'uploaded', subtitle_url: 'uploaded', lan_doc: '上传的字幕'}
infos_.push(tarInfo)
// set
const transcript = parseTranscript(file.name, text)
dispatch(setInfos(infos_))
dispatch(setCurInfo(tarInfo))
dispatch(setCurFetched(true))
dispatch(setUploadedTranscript(transcript))
dispatch(setData(transcript))
}
}
reader.readAsText(file)
}
input.click()
}, [dispatch, infos])
const selectCallback = useCallback((e: any) => {
if (e.target.value === 'upload') {
upload()
return
}
const tarInfo = find(infos, {subtitle_url: e.target.value})
if (curInfo?.id !== tarInfo?.id) {
dispatch(setCurInfo(tarInfo))
if (tarInfo && tarInfo.subtitle_url === 'uploaded') {
dispatch(setCurFetched(true))
dispatch(setData(uploadedTranscript))
} else {
dispatch(setCurFetched(false))
}
}
}, [curInfo?.id, dispatch, infos, upload, uploadedTranscript])
const preventCallback = useCallback((e: any) => {
e.stopPropagation()
}, [])
const onUpload = useCallback((e: any) => {
e.stopPropagation()
upload()
}, [upload])
return <div className='rounded-[6px] bg-[#f1f2f3] dark:bg-base-100 h-[44px] flex justify-between items-center cursor-pointer' onClick={foldCallback}>
<div className='shrink-0 flex items-center'>
<span className='shrink-0 text-[15px] font-medium pl-[16px] pr-[14px]'></span>
<MoreBtn placement={'right-start'}/>
</div>
<div className='flex gap-0.5 items-center mr-[16px]'>
{(infos == null) || infos.length <= 0
?<div className='text-xs desc'>
<button className='btn btn-xs btn-link' onClick={onUpload}>(vtt/srt)</button>
()
</div>
:<select disabled={!infos || infos.length <= 0} className='select select-ghost select-xs line-clamp-1' value={curInfo?.subtitle_url} onChange={selectCallback} onClick={preventCallback}>
{infos?.map((item: any) => <option key={item.id} value={item.subtitle_url}>{item.lan_doc}</option>)}
<option key='upload' value='upload'>(vtt/srt)</option>
</select>}
<IoIosArrowUp className={classNames('shrink-0 desc transform ease-in duration-300', fold?'rotate-180':'')}/>
</div>
</div>
}
export default Header

278
src/biz/MoreBtn.tsx Normal file
View File

@@ -0,0 +1,278 @@
import React, {MouseEvent, useCallback, useContext, useRef, useState} from 'react'
import {useClickAway} from 'ahooks'
import {
AiFillWechat,
BsFillChatDotsFill,
FiMoreVertical,
ImDownload3,
IoMdSettings,
RiFileCopy2Line
} from 'react-icons/all'
import Popover from '../components/Popover'
import {Placement} from '@popperjs/core/lib/enums'
import {useAppDispatch, useAppSelector} from '../hooks/redux'
import {setDownloadType, setEnvData, setPage} from '../redux/envReducer'
import {EventBusContext} from '../Router'
import {EVENT_EXPAND, PAGE_SETTINGS} from '../const'
import {formatSrtTime, formatTime, formatVttTime} from '../util/util'
import {downloadText, openUrl} from '@kky002/kky-util'
import toast from 'react-hot-toast'
import {getSummarize} from '../util/biz_util'
interface Props {
placement: Placement
}
const DownloadTypes = [
{
type: 'text',
name: '列表',
},
{
type: 'textWithTime',
name: '列表(带时间)',
},
{
type: 'article',
name: '文章',
},
{
type: 'srt',
name: 'srt',
},
{
type: 'vtt',
name: 'vtt',
},
{
type: 'json',
name: '原始json',
},
{
type: 'summarize',
name: '总结',
},
]
const MoreBtn = (props: Props) => {
const {placement} = props
const dispatch = useAppDispatch()
const moreRef = useRef(null)
const data = useAppSelector(state => state.env.data)
const envReady = useAppSelector(state => state.env.envReady)
const envData = useAppSelector(state => state.env.envData)
const downloadType = useAppSelector(state => state.env.downloadType)
const [moreVisible, setMoreVisible] = useState(false)
const eventBus = useContext(EventBusContext)
const segments = useAppSelector(state => state.env.segments)
const title = useAppSelector(state => state.env.title)
const curSummaryType = useAppSelector(state => state.env.curSummaryType)
const downloadCallback = useCallback((download: boolean) => {
if (data == null) {
return
}
let s, fileName
if (!downloadType || downloadType === 'text') {
s = ''
for (const item of data.body) {
s += item.content + '\n'
}
fileName = 'download.txt'
} else if (downloadType === 'textWithTime') {
s = ''
for (const item of data.body) {
s += formatTime(item.from) + ' ' + item.content + '\n'
}
fileName = 'download.txt'
} else if (downloadType === 'article') {
s = ''
for (const item of data.body) {
s += item.content + ', '
}
s = s.substring(0, s.length - 1) // remove last ','
fileName = 'download.txt'
} else if (downloadType === 'srt') {
/**
* 1
* 00:05:00,400 --> 00:05:15,300
* This is an example of
* a subtitle.
*
* 2
* 00:05:16,400 --> 00:05:25,300
* This is an example of
* a subtitle - 2nd subtitle.
*/
s = ''
for (const item of data.body) {
const ss = (item.idx + 1) + '\n' + formatSrtTime(item.from) + ' --> ' + formatSrtTime(item.to) + '\n' + ((item.content?.trim()) ?? '') + '\n\n'
s += ss
}
s = s.substring(0, s.length - 1)// remove last '\n'
fileName = 'download.srt'
} else if (downloadType === 'vtt') {
/**
* WEBVTT title
*
* 1
* 00:05:00.400 --> 00:05:15.300
* This is an example of
* a subtitle.
*
* 2
* 00:05:16.400 --> 00:05:25.300
* This is an example of
* a subtitle - 2nd subtitle.
*/
s = `WEBVTT ${title ?? ''}\n\n`
for (const item of data.body) {
const ss = (item.idx + 1) + '\n' + formatVttTime(item.from) + ' --> ' + formatVttTime(item.to) + '\n' + ((item.content?.trim()) ?? '') + '\n\n'
s += ss
}
s = s.substring(0, s.length - 1)// remove last '\n'
fileName = 'download.vtt'
} else if (downloadType === 'json') {
s = JSON.stringify(data)
fileName = 'download.json'
} else if (downloadType === 'summarize') {
const [success, content] = getSummarize(title, segments, curSummaryType)
if (!success) return
s = content
fileName = '总结.txt'
} else {
return
}
if (download) {
downloadText(s, fileName)
} else {
navigator.clipboard.writeText(s).then(() => {
toast.success('复制成功')
}).catch(console.error)
}
setMoreVisible(false)
}, [curSummaryType, data, downloadType, segments, title])
const downloadAudioCallback = useCallback(() => {
window.parent.postMessage({
type: 'downloadAudio',
}, '*')
}, [])
const selectCallback = useCallback((e: any) => {
dispatch(setDownloadType(e.target.value))
}, [dispatch])
const preventCallback = useCallback((e: any) => {
e.stopPropagation()
}, [])
const moreCallback = useCallback((e: MouseEvent) => {
e.stopPropagation()
if (!envData.flagDot) {
dispatch(setEnvData({
...envData,
flagDot: true,
}))
}
setMoreVisible(!moreVisible)
// 显示菜单时自动展开,防止菜单显示不全
if (!moreVisible) {
eventBus.emit({
type: EVENT_EXPAND
})
}
}, [dispatch, envData, eventBus, moreVisible])
useClickAway(() => {
setMoreVisible(false)
}, moreRef)
return <>
<div ref={moreRef} onClick={moreCallback}>
<div className='indicator flex items-center'>
{envReady && !envData.flagDot && <span className="indicator-item bg-secondary w-1.5 h-1.5 rounded-full"></span>}
<FiMoreVertical className='desc transform ease-in duration-300 hover:text-primary' title='更多'/>
</div>
</div>
{moreVisible &&
<Popover refElement={moreRef.current} className='bg-neutral text-neutral-content py-1 z-[1000]' options={{
placement
}}>
<ul className='menu menu-compact'>
<li className='hover:bg-accent'>
<a className='flex items-center' onClick={(e) => {
e.preventDefault()
e.stopPropagation()
downloadCallback(false)
}}>
<RiFileCopy2Line className='w-[20px] h-[20px] text-primary/75 bg-white rounded-sm p-0.5'/>
<select className='select select-ghost select-xs' value={downloadType} onChange={selectCallback}
onClick={preventCallback}>
{DownloadTypes?.map((item: any) => <option key={item.type} value={item.type}>{item.name}</option>)}
</select>
</a>
</li>
<li className='hover:bg-accent'>
<a className='flex items-center' onClick={(e) => {
e.preventDefault()
e.stopPropagation()
downloadCallback(true)
}}>
<ImDownload3 className='w-[20px] h-[20px] text-primary/75 bg-white rounded-sm p-0.5'/>
<select className='select select-ghost select-xs' value={downloadType} onChange={selectCallback}
onClick={preventCallback}>
{DownloadTypes?.map((item: any) => <option key={item.type} value={item.type}>{item.name}</option>)}
</select>
</a>
</li>
<li className='hover:bg-accent'>
<a className='flex items-center' onClick={(e) => {
e.preventDefault()
e.stopPropagation()
downloadAudioCallback()
}}>
<ImDownload3 className='w-[20px] h-[20px] text-primary/75 bg-white rounded-sm p-0.5'/>
(m4s)
</a>
</li>
<li className='hover:bg-accent'>
<a className='flex items-center' onClick={(e) => {
e.preventDefault()
e.stopPropagation()
openUrl('https://jq.qq.com/?_wv=1027&k=RJyFABPF')
}}>
<BsFillChatDotsFill className='w-[20px] h-[20px] text-primary/75 bg-white rounded-sm p-0.5'/>
QQ交流群(194536885)
</a>
</li>
<li className='hover:bg-accent'>
<a className='flex items-center' onClick={(e) => {
e.preventDefault()
e.stopPropagation()
openUrl('https://static.ssstab.com/images/indiekky_public.png')
}}>
<AiFillWechat className='w-[20px] h-[20px] text-primary/75 bg-white rounded-sm p-0.5'/>
(IndieKKY)
</a>
</li>
<li className='hover:bg-accent'>
<a className='flex items-center' onClick={(e) => {
dispatch(setPage(PAGE_SETTINGS))
setMoreVisible(false)
e.preventDefault()
e.stopPropagation()
}}>
<IoMdSettings className='w-[20px] h-[20px] text-primary/75 bg-white rounded-sm p-0.5'/>
</a>
</li>
</ul>
</Popover>}
</>
}
export default MoreBtn

View File

@@ -0,0 +1,35 @@
import React, {useMemo} from 'react'
import {formatTime} from '../util/util'
import {useAppSelector} from '../hooks/redux'
import {getDisplay, getTransText} from '../util/biz_util'
import classNames from 'classnames'
const NormalSegmentItem = (props: {
item: {
from: number
to: number
content: string
}
idx: number
isIn: boolean
moveCallback: (event: any) => void
}) => {
const {item, idx, isIn, moveCallback} = props
const transResult = useAppSelector(state => state.env.transResults[idx])
const envData = useAppSelector(state => state.env.envData)
const fontSize = useAppSelector(state => state.env.envData.fontSize)
const autoTranslate = useAppSelector(state => state.env.autoTranslate)
const transText = useMemo(() => getTransText(transResult, envData.hideOnDisableAutoTranslate, autoTranslate), [autoTranslate, envData.hideOnDisableAutoTranslate, transResult])
const display = useMemo(() => getDisplay(envData.transDisplay, item.content, transText), [envData.transDisplay, item.content, transText])
return <div className={classNames('flex py-0.5 cursor-pointer rounded-sm hover:bg-base-200', fontSize === 'large'?'text-sm':'text-xs')}
onClick={moveCallback}>
<div className='desc w-[66px] flex justify-center'>{formatTime(item.from)}</div>
<div className={'flex-1'}>
<div className={classNames('font-medium', isIn ? 'text-primary underline' : '')}>{display.main}</div>
{display.sub && <div className='desc'>{display.sub}</div>}
</div>
</div>
}
export default NormalSegmentItem

239
src/biz/SegmentCard.tsx Normal file
View File

@@ -0,0 +1,239 @@
import React, {MutableRefObject, useCallback, useEffect, useMemo, useRef} from 'react'
import {useAppDispatch, useAppSelector} from '../hooks/redux'
import {setCurSummaryType, setFloatKeyPointsSegIdx, setPage, setSegmentFold} from '../redux/envReducer'
import classNames from 'classnames'
import {FaClipboardList} from 'react-icons/fa'
import {PAGE_MAIN, PAGE_SETTINGS, SUMMARIZE_THRESHOLD} from '../const'
import useTranslate from '../hooks/useTranslate'
import {BsDashSquare, BsPlusSquare, CgFileDocument, GrOverview, RiFileCopy2Line} from 'react-icons/all'
import toast from 'react-hot-toast'
import {getLastTime, getSummaryStr, isSummaryEmpty, parseStrTimeToSeconds} from '../util/biz_util'
import {useInViewport} from 'ahooks'
import SegmentItem from './SegmentItem'
import {stopPopFunc} from '../util/util'
import useSubtitle from '../hooks/useSubtitle'
const SummarizeItemOverview = (props: {
segment: Segment
summary: OverviewSummary
segmentIdx: number
overviewItem: OverviewItem
idx: number
}) => {
const { segment, summary, segmentIdx, overviewItem, idx} = props
const {move} = useSubtitle()
const time = parseStrTimeToSeconds(overviewItem.time)
const currentTime = useAppSelector(state => state.env.currentTime)
const isIn = useMemo(() => {
if (currentTime != null) {
// check in current segment
if (segment.items?.length > 0) {
const startTime = segment.items[0].from
const lastTime = segment.items[segment.items.length - 1].to
if (currentTime >= startTime && currentTime < lastTime) {
// check in current overview item
const nextOverviewItem = summary.content?.[idx + 1]
const nextTime = (nextOverviewItem != null)?parseStrTimeToSeconds(nextOverviewItem.time):null
return currentTime >= time && (nextTime == null || currentTime < nextTime)
}
}
}
return false
}, [currentTime, idx, segment.items, summary.content, time])
const moveCallback = useCallback((event: any) => {
if (event.altKey) { // 复制
navigator.clipboard.writeText(overviewItem.key).catch(console.error)
} else {
move(time)
}
}, [overviewItem.key, move, time])
return <li className='flex items-center gap-1 relative cursor-pointer p-0.5 rounded-sm hover:bg-base-200' onClick={moveCallback}>
<span className='absolute left-[-16px] top-auto bottom-auto'>{overviewItem.emoji}</span>
<span className='bg-success/75 rounded-sm px-1'>{overviewItem.time}</span>
<span className={classNames(isIn ? 'text-primary underline' : '')}>{overviewItem.key}</span>
</li>
}
const Summarize = (props: {
segment: Segment
segmentIdx: number
summary?: Summary
float?: boolean
}) => {
const {segment, segmentIdx, summary, float} = props
const dispatch = useAppDispatch()
const apiKey = useAppSelector(state => state.env.envData.apiKey)
const fontSize = useAppSelector(state => state.env.envData.fontSize)
const title = useAppSelector(state => state.env.title)
const curSummaryType = useAppSelector(state => state.env.curSummaryType)
const {addSummarizeTask} = useTranslate()
const onGenerate = useCallback(() => {
if (apiKey) {
addSummarizeTask(title, curSummaryType, segment).catch(console.error)
} else {
dispatch(setPage(PAGE_SETTINGS))
toast.error('需要先设置ApiKey!')
}
}, [addSummarizeTask, apiKey, curSummaryType, dispatch, segment, title])
const onCopy = useCallback(() => {
if (summary != null) {
navigator.clipboard.writeText(getSummaryStr(summary)).then(() => {
toast.success('已复制到剪贴板!')
}).catch(console.error)
}
}, [summary])
return <div className='flex flex-col gap-0.5 relative'>
{(summary != null) && !isSummaryEmpty(summary) && <div className='absolute top-0 right-0'>
<RiFileCopy2Line className='desc cursor-pointer' onClick={onCopy}/>
</div>}
<div className='flex justify-center items-center'>
{summary?.type === 'overview' && (summary.content != null) &&
<ul className={classNames('font-medium list-none max-w-[90%]', fontSize === 'large' ? 'text-sm' : 'text-xs')}>
{(summary.content).map((overviewItem: OverviewItem, idx: number) =>
<SummarizeItemOverview key={idx} idx={idx} summary={summary} overviewItem={overviewItem} segment={segment} segmentIdx={segmentIdx}/>)}
</ul>}
{summary?.type === 'keypoint' && (summary.content != null) &&
<ul className={classNames('font-medium list-disc max-w-[90%]', fontSize === 'large' ? 'text-sm' : 'text-xs')}>
{summary.content?.map((keyPoint: string, idx: number) => <li key={idx}>{keyPoint}</li>)}
</ul>}
{summary?.type === 'brief' && (summary.content != null) &&
<div className={classNames('font-medium max-w-[90%]', fontSize === 'large' ? 'text-sm' : 'text-xs')}>
{summary.content.summary}
</div>}
</div>
<div className='flex flex-col justify-center items-center'>
{segment.text.length < SUMMARIZE_THRESHOLD && <div className='desc-lighter text-xs'>.</div>}
{segment.text.length >= SUMMARIZE_THRESHOLD && ((summary == null) || summary.status !== 'done' || summary.error) && <button disabled={summary?.status === 'pending'}
className={classNames('btn btn-link btn-xs', summary?.status === 'pending' && 'loading')}
onClick={onGenerate}>{(summary == null) || summary.status === 'init' ? '点击生成' : (summary.status === 'pending' ? '生成中' : '重新生成')}</button>}
{summary?.error && <div className='text-xs text-error'>{summary?.error}</div>}
</div>
{!float && <div className='mx-2 my-1 h-[1px] bg-base-300'></div>}
</div>
}
const SegmentCard = (props: {
bodyRef: MutableRefObject<any>
segment: Segment
segmentIdx: number
}) => {
const {bodyRef, segment, segmentIdx} = props
const dispatch = useAppDispatch()
const summarizeRef = useRef<any>(null)
const [inViewport] = useInViewport(summarizeRef, {
root: bodyRef.current,
})
const segments = useAppSelector(state => state.env.segments)
const needScroll = useAppSelector(state => state.env.needScroll)
const curIdx = useAppSelector(state => state.env.curIdx)
const summarizeEnable = useAppSelector(state => state.env.envData.summarizeEnable)
const summarizeFloat = useAppSelector(state => state.env.envData.summarizeFloat)
const fold = useAppSelector(state => state.env.fold)
const page = useAppSelector(state => state.env.page)
const compact = useAppSelector(state => state.env.compact)
const floatKeyPointsSegIdx = useAppSelector(state => state.env.floatKeyPointsSegIdx)
const showCurrent = useMemo(() => curIdx != null && segment.startIdx <= curIdx && curIdx <= segment.endIdx, [curIdx, segment.endIdx, segment.startIdx])
const curSummaryType = useAppSelector(state => state.env.curSummaryType)
const summary = useMemo(() => {
const result = segment.summaries[curSummaryType]
if (result) {
return result
}
return undefined
}, [curSummaryType, segment.summaries])
const onFold = useCallback(() => {
dispatch(setSegmentFold({
segmentStartIdx: segment.startIdx,
fold: !segment.fold
}))
}, [dispatch, segment.fold, segment.startIdx])
// 检测设置floatKeyPointsSegIdx
useEffect(() => {
if (summarizeFloat) { // 已启用
if (!fold && page === PAGE_MAIN && showCurrent) { // 当前Card有控制权
if (!inViewport && (summary != null) && !isSummaryEmpty(summary)) {
dispatch(setFloatKeyPointsSegIdx(segment.startIdx))
} else {
dispatch(setFloatKeyPointsSegIdx())
}
}
}
}, [dispatch, fold, inViewport, page, segment.startIdx, showCurrent, summarizeFloat, summary])
const onSelBrief = useCallback(() => {
dispatch(setCurSummaryType('brief'))
}, [dispatch])
const onSelOverview = useCallback(() => {
dispatch(setCurSummaryType('overview'))
}, [dispatch])
const onSelKeypoint = useCallback(() => {
dispatch(setCurSummaryType('keypoint'))
}, [dispatch])
return <div
className={classNames('border border-base-300 bg-base-200/25 rounded flex flex-col m-1.5 p-1.5 gap-1 shadow', showCurrent && 'shadow-primary')}>
<div className='relative flex justify-center min-h-[20px]'>
{segments != null && segments.length > 1 &&
<div className='absolute left-0 top-0 bottom-0 text-xs select-none flex-center desc'>
{segment.fold
? <BsPlusSquare className='cursor-pointer' onClick={onFold}/> :
<BsDashSquare className='cursor-pointer' onClick={onFold}/>}
</div>}
{summarizeEnable && <div className="tabs">
<a className="tab tab-lifted tab-xs tab-disabled cursor-default"></a>
<a className={classNames('tab tab-lifted tab-xs', curSummaryType === 'brief' && 'tab-active')} onClick={onSelBrief}><CgFileDocument/></a>
<a className={classNames('tab tab-lifted tab-xs', curSummaryType === 'overview' && 'tab-active')} onClick={onSelOverview}><GrOverview/></a>
<a className={classNames('tab tab-lifted tab-xs', curSummaryType === 'keypoint' && 'tab-active')} onClick={onSelKeypoint}><FaClipboardList/></a>
<a className="tab tab-lifted tab-xs tab-disabled cursor-default"></a>
</div>}
<div
className='absolute right-0 top-0 bottom-0 text-xs desc-lighter select-none flex-center'>{getLastTime(segment.items[segment.items.length - 1].to - segment.items[0].from)}</div>
</div>
{summarizeEnable && <div ref={summarizeRef}>
<Summarize segment={segment} segmentIdx={segmentIdx} summary={summary}/>
</div>}
{!segment.fold
? <div>
{!compact && <div className='desc text-xs flex py-0.5'>
<div className='w-[66px] flex justify-center'></div>
<div className='flex-1'></div>
</div>}
{segment.items.map((item: TranscriptItem, idx: number) => <SegmentItem key={item.idx}
bodyRef={bodyRef}
item={item}
idx={segment.startIdx + idx}
isIn={curIdx === segment.startIdx + idx}
needScroll={needScroll && curIdx === segment.startIdx + idx}
last={idx === segment.items.length - 1}
/>)}
{segments != null && segments.length > 1 && <div className='flex justify-center'><a className='link text-xs'
onClick={onFold}>{segment.items.length}</a>
</div>}
</div>
: <div className='flex justify-center'><a className='link text-xs'
onClick={onFold}>{segment.items.length},</a>
</div>}
{floatKeyPointsSegIdx === segment.startIdx && <div
className='absolute bottom-0 left-0 right-0 z-[200] border-t bg-base-100 text-primary-content shadow max-h-[100px] overflow-y-auto scrollbar-hide'
onWheel={stopPopFunc}
>
<div className='bg-primary/50 p-2'>
<Summarize segment={segment} segmentIdx={segmentIdx} summary={summary} float/>
</div>
</div>}
</div>
}
export default SegmentCard

70
src/biz/SegmentItem.tsx Normal file
View File

@@ -0,0 +1,70 @@
import React, {useCallback, useEffect, useRef} from 'react'
import {useAppDispatch, useAppSelector} from '../hooks/redux'
import useSubtitle from '../hooks/useSubtitle'
import {setCheckAutoScroll, setCurOffsetTop, setNeedScroll} from '../redux/envReducer'
import NormalSegmentItem from './NormalSegmentItem'
import CompactSegmentItem from './CompactSegmentItem'
const SegmentItem = (props: {
bodyRef: any
item: {
from: number
to: number
content: string
}
idx: number
isIn: boolean
needScroll?: boolean
last: boolean
}) => {
const dispatch = useAppDispatch()
const {bodyRef, item, idx, isIn, needScroll, last} = props
const ref = useRef<any>()
const {move} = useSubtitle()
const compact = useAppSelector(state => state.env.compact)
const moveCallback = useCallback((event: any) => {
if (event.altKey) { // 复制
navigator.clipboard.writeText(item.content).catch(console.error)
} else {
move(item.from)
}
}, [item.content, item.from, move])
// 检测需要滚动进入视野
useEffect(() => {
if (needScroll) {
bodyRef.current.scrollTop = ref.current.offsetTop - bodyRef.current.offsetTop - 40
dispatch(setNeedScroll(false))
}
}, [dispatch, needScroll, bodyRef])
// 进入时更新当前offsetTop
useEffect(() => {
if (isIn) {
dispatch(setCurOffsetTop(ref.current.offsetTop))
dispatch(setCheckAutoScroll(true))
}
}, [dispatch, isIn])
return <span ref={ref}>
{compact
? <CompactSegmentItem
item={item}
idx={idx}
isIn={isIn}
last={last}
moveCallback={moveCallback}
/>
:
<NormalSegmentItem
item={item}
idx={idx}
isIn={isIn}
moveCallback={moveCallback}
/>
}
</span>
}
export default SegmentItem

277
src/biz/Settings.tsx Normal file
View File

@@ -0,0 +1,277 @@
import React, {PropsWithChildren, useCallback, useMemo, useState} from 'react'
import {setEnvData, setPage} from '../redux/envReducer'
import {useAppDispatch, useAppSelector} from '../hooks/redux'
import {
HEADER_HEIGHT,
LANGUAGE_DEFAULT,
LANGUAGES,
PAGE_MAIN,
SERVER_URL_THIRD,
SUMMARIZE_LANGUAGE_DEFAULT,
TRANSLATE_FETCH_DEFAULT,
TRANSLATE_FETCH_MAX,
TRANSLATE_FETCH_MIN,
TRANSLATE_FETCH_STEP,
WORDS_DEFAULT,
WORDS_MAX,
WORDS_MIN,
WORDS_STEP
} from '../const'
import {IoWarning} from 'react-icons/all'
import classNames from 'classnames'
import toast from 'react-hot-toast'
import {useBoolean, useEventTarget} from 'ahooks'
import {useEventChecked} from '@kky002/kky-hooks'
const Section = (props: {
title: ShowElement
htmlFor?: string
} & PropsWithChildren) => {
const {title, htmlFor, children} = props
return <div className='flex flex-col gap-1'>
<label className='font-medium desc-lighter text-xs' htmlFor={htmlFor}>{title}</label>
<div className='flex flex-col gap-1 rounded py-2 px-2 bg-base-200/75'>{children}</div>
</div>
}
const FormItem = (props: {
title: ShowElement
tip?: string
htmlFor?: string
} & PropsWithChildren) => {
const {title, tip, htmlFor, children} = props
return <div className='flex items-center gap-2'>
<div className={classNames('basis-3/12 flex-center', tip && 'tooltip tooltip-right z-[100] underline underline-offset-2 decoration-dashed')} data-tip={tip}>
<label className='font-medium desc' htmlFor={htmlFor}>{title}</label>
</div>
<div className='basis-9/12 flex items-center'>
{children}
</div>
</div>
}
const Settings = () => {
const dispatch = useAppDispatch()
const envData = useAppSelector(state => state.env.envData)
const {value: autoExpandValue, onChange: setAutoExpandValue} = useEventChecked(envData.autoExpand)
// const {value: autoScrollValue, onChange: setAutoScrollValue} = useEventChecked(envData.autoScroll)
const {value: translateEnableValue, onChange: setTranslateEnableValue} = useEventChecked(envData.translateEnable)
const {value: summarizeEnableValue, onChange: setSummarizeEnableValue} = useEventChecked(envData.summarizeEnable)
const {value: summarizeFloatValue, onChange: setSummarizeFloatValue} = useEventChecked(envData.summarizeFloat)
const [apiKeyValue, { onChange: onChangeApiKeyValue }] = useEventTarget({initialValue: envData.apiKey??''})
const [serverUrlValue, setServerUrlValue] = useState(envData.serverUrl)
const [languageValue, { onChange: onChangeLanguageValue }] = useEventTarget({initialValue: envData.language??LANGUAGE_DEFAULT})
const [summarizeLanguageValue, { onChange: onChangeSummarizeLanguageValue }] = useEventTarget({initialValue: envData.summarizeLanguage??SUMMARIZE_LANGUAGE_DEFAULT})
const [hideOnDisableAutoTranslateValue, setHideOnDisableAutoTranslateValue] = useState(envData.hideOnDisableAutoTranslate)
const [themeValue, setThemeValue] = useState(envData.theme)
const [fontSizeValue, setFontSizeValue] = useState(envData.fontSize)
const [transDisplayValue, setTransDisplayValue] = useState(envData.transDisplay)
const [wordsValue, setWordsValue] = useState(envData.words??WORDS_DEFAULT)
const [fetchAmountValue, setFetchAmountValue] = useState(envData.fetchAmount??TRANSLATE_FETCH_DEFAULT)
const [moreFold, {toggle: toggleMoreFold}] = useBoolean(true)
const fold = useAppSelector(state => state.env.fold)
const totalHeight = useAppSelector(state => state.env.totalHeight)
const wordsList = useMemo(() => {
const list = []
for (let i = WORDS_MIN; i <= WORDS_MAX; i += WORDS_STEP) {
list.push(i)
}
return list
}, [])
const transFetchAmountList = useMemo(() => {
const list = []
for (let i = TRANSLATE_FETCH_MIN; i <= TRANSLATE_FETCH_MAX; i += TRANSLATE_FETCH_STEP) {
list.push(i)
}
return list
}, [])
const onChangeHideOnDisableAutoTranslate = useCallback((e: any) => {
setHideOnDisableAutoTranslateValue(e.target.checked)
}, [])
const onSave = useCallback(() => {
dispatch(setEnvData({
autoExpand: autoExpandValue,
apiKey: apiKeyValue,
serverUrl: serverUrlValue,
translateEnable: translateEnableValue,
language: languageValue,
hideOnDisableAutoTranslate: hideOnDisableAutoTranslateValue,
theme: themeValue,
transDisplay: transDisplayValue,
summarizeEnable: summarizeEnableValue,
summarizeFloat: summarizeFloatValue,
summarizeLanguage: summarizeLanguageValue,
words: wordsValue,
fetchAmount: fetchAmountValue,
fontSize: fontSizeValue,
}))
dispatch(setPage(PAGE_MAIN))
toast.success('保存成功')
}, [fontSizeValue, apiKeyValue, autoExpandValue, dispatch, fetchAmountValue, hideOnDisableAutoTranslateValue, languageValue, serverUrlValue, summarizeEnableValue, summarizeFloatValue, summarizeLanguageValue, themeValue, transDisplayValue, translateEnableValue, wordsValue])
const onCancel = useCallback(() => {
dispatch(setPage(PAGE_MAIN))
}, [dispatch])
const onFetchAmountChange = useCallback((e: any) => {
setFetchAmountValue(parseInt(e.target.value))
}, [])
const onWordsChange = useCallback((e: any) => {
setWordsValue(parseInt(e.target.value))
}, [])
const onSel1 = useCallback(() => {
setTransDisplayValue('originPrimary')
}, [])
const onSel2 = useCallback(() => {
setTransDisplayValue('targetPrimary')
}, [])
const onSel3 = useCallback(() => {
setTransDisplayValue('target')
}, [])
const onSelTheme1 = useCallback(() => {
setThemeValue('system')
}, [])
const onSelTheme2 = useCallback(() => {
setThemeValue('light')
}, [])
const onSelTheme3 = useCallback(() => {
setThemeValue('dark')
}, [])
const onSelFontSize1 = useCallback(() => {
setFontSizeValue('normal')
}, [])
const onSelFontSize2 = useCallback(() => {
setFontSizeValue('large')
}, [])
return <div className='text-sm overflow-y-auto' style={{
height: fold?undefined:`${totalHeight-HEADER_HEIGHT}px`,
}}>
<div className="flex flex-col gap-3 p-2">
<Section title='通用配置'>
<FormItem title='自动展开' htmlFor='autoExpand' tip='是否视频有字幕时自动展开字幕列表'>
<input id='autoExpand' type='checkbox' className='toggle toggle-primary' checked={autoExpandValue}
onChange={setAutoExpandValue}/>
</FormItem>
<FormItem title='主题'>
<div className="btn-group">
<button onClick={onSelTheme1} className={classNames('btn btn-xs no-animation', (!themeValue || themeValue === 'system')?'btn-active':'')}></button>
<button onClick={onSelTheme2} className={classNames('btn btn-xs no-animation', themeValue === 'light'?'btn-active':'')}></button>
<button onClick={onSelTheme3} className={classNames('btn btn-xs no-animation', themeValue === 'dark'?'btn-active':'')}></button>
</div>
</FormItem>
<FormItem title='字体大小'>
<div className="btn-group">
<button onClick={onSelFontSize1} className={classNames('btn btn-xs no-animation', (!fontSizeValue || fontSizeValue === 'normal')?'btn-active':'')}></button>
<button onClick={onSelFontSize2} className={classNames('btn btn-xs no-animation', fontSizeValue === 'large'?'btn-active':'')}></button>
</div>
</FormItem>
</Section>
<Section title='openai配置'>
<FormItem title='ApiKey' htmlFor='apiKey'>
<input id='apiKey' type='text' className='input input-sm input-bordered w-full' placeholder='sk-xxx' value={apiKeyValue} onChange={onChangeApiKeyValue}/>
</FormItem>
<FormItem title='服务器' htmlFor='serverUrl'>
<input id='serverUrl' type='text' className='input input-sm input-bordered w-full' placeholder='服务器地址,默认使用官方地址' value={serverUrlValue} onChange={e => setServerUrlValue(e.target.value)}/>
</FormItem>
<div className='flex justify-center'>
<a className='link text-xs' onClick={toggleMoreFold}>{moreFold?'点击查看说明':'点击折叠说明'}</a>
</div>
{!moreFold && <div>
<ul className='pl-3 list-decimal desc text-xs'>
<li>访</li>
<li><a className='link' href='https://platform.openai.com/' target='_blank' rel="noreferrer">openai.com</a></li>
<li>(使ApiKey)<a className='link' onClick={() => setServerUrlValue(SERVER_URL_THIRD)} rel='noreferrer'></a></li>
<li>(ApiKey)<a className='link' href='https://api2d.com/' target='_blank' rel="noreferrer">api2d</a> | <a className='link' onClick={() => setServerUrlValue('https://openai.api2d.net')} rel='noreferrer'></a></li>
<li>(ApiKey)<a className='link' href='https://openaimax.com/' target='_blank' rel="noreferrer">OpenAI-Max</a> | <a className='link' onClick={() => setServerUrlValue('https://api.openaimax.com')} rel='noreferrer'></a></li>
<li>(ApiKey)<a className='link' href='https://openai-sb.com/' target='_blank' rel="noreferrer">OpenAI-SB</a> | <a className='link' onClick={() => setServerUrlValue('https://api.openai-sb.com')} rel='noreferrer'></a></li>
<li>(ApiKey)<a className='link' href='https://www.ohmygpt.com/' target='_blank' rel="noreferrer">OhMyGPT</a> | <a className='link' onClick={() => setServerUrlValue('https://api.ohmygpt.com')} rel='noreferrer'></a></li>
<li>(ApiKey)<a className='link' href='https://aiproxy.io/' target='_blank' rel="noreferrer">AIProxy</a> | <a className='link' onClick={() => setServerUrlValue('https://api.aiproxy.io')} rel='noreferrer'></a></li>
<li>(ApiKey)<a className='link' href='https://key-rental.bowen.cool/' target='_blank' rel="noreferrer">Key Rental</a> | <a className='link' onClick={() => setServerUrlValue('https://key-rental-api.bowen.cool/openai')} rel='noreferrer'></a></li>
<li></li>
</ul>
</div>}
</Section>
<Section title={<div className='flex items-center'>
{!apiKeyValue && <div className='tooltip tooltip-right ml-1' data-tip='未设置ApiKey无法使用'>
<IoWarning className='text-sm text-warning'/>
</div>}
</div>}>
<FormItem title='启用翻译' htmlFor='translateEnable'>
<input id='translateEnable' type='checkbox' className='toggle toggle-primary' checked={translateEnableValue}
onChange={setTranslateEnableValue}/>
</FormItem>
<FormItem title='目标语言' htmlFor='language'>
<select id='language' className="select select-sm select-bordered" value={languageValue} onChange={onChangeLanguageValue}>
{LANGUAGES.map(language => <option key={language.code} value={language.code}>{language.name}</option>)}
</select>
</FormItem>
<FormItem title='翻译条数' tip='每次翻译条数'>
<div className='flex-1 flex flex-col'>
<input type="range" min={TRANSLATE_FETCH_MIN} max={TRANSLATE_FETCH_MAX} step={TRANSLATE_FETCH_STEP} value={fetchAmountValue} className="range range-primary" onChange={onFetchAmountChange} />
<div className="w-full flex justify-between text-xs px-2">
{transFetchAmountList.map(amount => <span key={amount}>{amount}</span>)}
</div>
</div>
</FormItem>
<FormItem title='翻译显示'>
<div className="btn-group">
<button onClick={onSel1} className={classNames('btn btn-xs no-animation', (!transDisplayValue || transDisplayValue === 'originPrimary')?'btn-active':'')}></button>
<button onClick={onSel2} className={classNames('btn btn-xs no-animation', transDisplayValue === 'targetPrimary'?'btn-active':'')}></button>
<button onClick={onSel3} className={classNames('btn btn-xs no-animation', transDisplayValue === 'target'?'btn-active':'')}></button>
</div>
</FormItem>
<FormItem title='隐藏翻译' tip='取消自动翻译时,隐藏已翻译内容' htmlFor='hideOnDisableAutoTranslate'>
<input id='hideOnDisableAutoTranslate' type='checkbox' className='toggle toggle-primary' checked={hideOnDisableAutoTranslateValue}
onChange={onChangeHideOnDisableAutoTranslate}/>
</FormItem>
</Section>
<Section title={<div className='flex items-center'>
{!apiKeyValue && <div className='tooltip tooltip-right ml-1' data-tip='未设置ApiKey无法使用'>
<IoWarning className='text-sm text-warning'/>
</div>}
</div>}>
<FormItem title='启用总结' htmlFor='summarizeEnable'>
<input id='summarizeEnable' type='checkbox' className='toggle toggle-primary' checked={summarizeEnableValue}
onChange={setSummarizeEnableValue}/>
</FormItem>
<FormItem title='浮动窗口' htmlFor='summarizeFloat' tip='当前总结离开视野时,是否显示浮动窗口'>
<input id='summarizeFloat' type='checkbox' className='toggle toggle-primary' checked={summarizeFloatValue}
onChange={setSummarizeFloatValue}/>
</FormItem>
<FormItem title='总结语言' htmlFor='summarizeLanguage'>
<select id='summarizeLanguage' className="select select-sm select-bordered" value={summarizeLanguageValue} onChange={onChangeSummarizeLanguageValue}>
{LANGUAGES.map(language => <option key={language.code} value={language.code}>{language.name}</option>)}
</select>
</FormItem>
<FormItem title='分段字数'>
<div className='flex-1 flex flex-col'>
<input type="range" min={WORDS_MIN} max={WORDS_MAX} step={WORDS_STEP} value={wordsValue} className="range range-primary" onChange={onWordsChange} />
<div className="w-full flex justify-between text-xs px-2">
{wordsList.map(words => <span key={words}>{words}</span>)}
</div>
</div>
</FormItem>
</Section>
<div className='flex justify-center gap-5'>
<button className='btn btn-primary btn-sm' onClick={onSave}></button>
<button className='btn btn-sm' onClick={onCancel}></button>
</div>
</div>
</div>
}
export default Settings

72
src/chrome/background.ts Normal file
View File

@@ -0,0 +1,72 @@
import {v4} from 'uuid'
import {handleTask, initTaskService, tasksMap} from './taskService'
/**
* 消息处理入口
* 注意需要异步sendResponse时返回true
*/
chrome.runtime.onMessage.addListener((event, sender, sendResponse) => {
console.debug('收到请求: ', event)
if (event.type === 'p') { // 发出http请求
const {url, options} = event
// 发出请求
fetch('http://localhost:27081/oproxy/p', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url,
options,
}),
}).then(async res => await res.json()).then(sendResponse).catch(console.error)
return true
} else if (event.type === 'syncGet') { // sync.get
chrome.storage.sync.get(event.keys, data => {
sendResponse(data)
})
return true
} else if (event.type === 'syncSet') { // sync.set
chrome.storage.sync.set(event.items).catch(console.error)
} else if (event.type === 'syncRemove') { // sync.remove
chrome.storage.sync.remove(event.keys).catch(console.error)
} else if (event.type === 'addTask') {
// 新建任务
const task: Task = {
id: v4(),
startTime: Date.now(),
status: 'pending',
def: event.taskDef,
}
tasksMap.set(task.id, task)
// 立即触发任务
handleTask(task).catch(console.error)
// 返回任务信息
sendResponse(task)
} else if (event.type === 'getTask') {
// 返回任务信息
const taskId = event.taskId
const task = tasksMap.get(taskId)
if (task == null) {
sendResponse({
code: 'not_found',
})
return
}
// 检测删除缓存
if (task.status === 'done') {
tasksMap.delete(taskId)
}
// 返回任务
sendResponse({
code: 'ok',
task,
})
}
})
initTaskService()

View File

@@ -0,0 +1,238 @@
const {TOTAL_HEIGHT_DEF, HEADER_HEIGHT, TOTAL_HEIGHT_MIN, TOTAL_HEIGHT_MAX} = require("../const");
var totalHeight = TOTAL_HEIGHT_DEF
const getVideoElement = () => {
const videoWrapper = document.getElementById('bilibili-player')
return videoWrapper.querySelector('video')
}
var danmukuBoxLoaded = false
setInterval(function () {
if (danmukuBoxLoaded) return
var danmukuBox = document.getElementById('danmukuBox')
if (danmukuBox) {
danmukuBoxLoaded = true
setTimeout(function () {
var vKey = ''
for (const key in danmukuBox?.dataset) {
if (key.startsWith('v-')) {
vKey = key
break
}
}
const iframe = document.createElement('iframe')
iframe.id = 'bilibili-subtitle-iframe'
iframe.src = chrome.runtime.getURL('index.html')
iframe.style = 'border: none; width: 100%; height: 44px;'
iframe.allow = 'clipboard-read; clipboard-write;'
if (vKey) {
iframe.dataset[vKey] = danmukuBox?.dataset[vKey]
}
//insert before first child
danmukuBox?.insertBefore(iframe, danmukuBox?.firstChild)
}, 1500)
}
}, 1000)
let aid = 0
let title = ''
let pages = []
let pagesMap = {}
let lastAidOrBvid = null
const refreshVideoInfo = async () => {
const iframe = document.getElementById('bilibili-subtitle-iframe')
if (!iframe) return
let path = location.pathname
if (path.endsWith('/')) {
path = path.slice(0, -1)
}
const paths = path.split('/')
const aidOrBvid = paths[paths.length - 1]
if (aidOrBvid !== lastAidOrBvid) {
// console.debug('refreshVideoInfo')
lastAidOrBvid = aidOrBvid
if (aidOrBvid) {
//aid,pages
let cid
let subtitles
if (aidOrBvid.toLowerCase().startsWith('av')) {//avxxx
title = ''
aid = aidOrBvid.slice(2)
cid = 1
pages = await fetch(`https://api.bilibili.com/x/player/pagelist?aid=${aid}`, {credentials: 'include'}).then(res => res.json()).then(res => res.data)
subtitles = await fetch(`https://api.bilibili.com/x/player/v2?aid=${aid}&cid=${cid}`, {credentials: 'include'}).then(res => res.json()).then(res => res.data.subtitle.subtitles)
} else {//bvxxx
pages = await fetch(`https://api.bilibili.com/x/web-interface/view?bvid=${aidOrBvid}`, {credentials: 'include'}).then(res => res.json()).then(res => {
title = res.data.title
aid = res.data.aid
cid = res.data.cid
subtitles = res.data.subtitle.list
return res.data.pages
})
}
//pagesMap
pagesMap = {}
pages.forEach(page => {
pagesMap[page.page + ''] = page
})
console.debug('refreshVideoInfo: ', aid, cid, pages, subtitles)
//send setVideoInfo
iframe.contentWindow.postMessage({
type: 'setVideoInfo',
title,
aid,
pages,
infos: subtitles,
}, '*')
}
}
}
let lastAid = null
let lastCid = null
const refreshSubtitles = () => {
const iframe = document.getElementById('bilibili-subtitle-iframe')
if (!iframe) return
const urlSearchParams = new URLSearchParams(window.location.search)
const p = urlSearchParams.get('p') || 1
const page = pagesMap[p]
if (!page) return
const cid = page.cid
if (aid !== lastAid || cid !== lastCid) {
console.debug('refreshSubtitles', aid, cid)
lastAid = aid
lastCid = cid
if (aid && cid) {
fetch(`https://api.bilibili.com/x/player/v2?aid=${aid}&cid=${cid}`, {
credentials: 'include',
})
.then(res => res.json())
.then(res => {
// console.log('refreshSubtitles: ', aid, cid, res)
iframe.contentWindow.postMessage({
type: 'setInfos',
infos: res.data.subtitle.subtitles
}, '*')
})
}
}
}
// 监听消息
window.addEventListener("message", (event) => {
const {data} = event
if (data.type === 'fold') {
const iframe = document.getElementById('bilibili-subtitle-iframe')
iframe.style.height = (data.fold ? HEADER_HEIGHT : totalHeight) + 'px'
}
if (data.type === 'move') {
const video = getVideoElement()
if (video) {
video.currentTime = data.time
}
}
//刷新视频信息
if (data.type === 'refreshVideoInfo') {
refreshVideoInfo().catch(console.error)
}
//刷新字幕
if (data.type === 'refreshSubtitles') {
refreshSubtitles()
}
if (data.type === 'getSubtitle') {
let url = data.info.subtitle_url
if (url.startsWith('http://')) {
url = url.replace('http://', 'https://')
}
fetch(url).then(res => res.json()).then(res => {
event.source.postMessage({
data: {
info: data.info,
data: res,
}, type: 'setSubtitle'
}, '*')
})
}
if (data.type === 'getCurrentTime') {
const video = getVideoElement()
if (video) {
event.source.postMessage({
data: {
currentTime: video.currentTime
}, type: 'setCurrentTime'
}, '*')
}
}
if (data.type === 'getSettings') {
const videoElement = getVideoElement()
totalHeight = videoElement ? Math.min(Math.max(videoElement.offsetHeight, TOTAL_HEIGHT_MIN), TOTAL_HEIGHT_MAX) : TOTAL_HEIGHT_DEF
event.source.postMessage({
data: {
noVideo: !videoElement,
totalHeight,
}, type: 'setSettings'
}, '*')
}
if (data.type === 'downloadAudio') {
const html = document.getElementsByTagName('html')[0].innerHTML
const playInfo = JSON.parse(html.match(/window.__playinfo__=(.+?)<\/script/)[1])
const audioUrl = playInfo.data.dash.audio[0].baseUrl
fetch(audioUrl).then(res => res.blob()).then(blob => {
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `${title}.m4s`
a.click()
})
}
if (data.type === 'updateTransResult') {
const trans = data.result??''
let text = document.getElementById('trans-result-text')
if (text) {
text.innerHTML = trans
} else {
const container = document.getElementsByClassName('bpx-player-subtitle-panel-wrap')?.[0]
if (container) {
const div = document.createElement('div')
div.style.display = 'flex'
div.style.justifyContent = 'center'
div.style.margin = '2px'
text = document.createElement('text')
text.id = 'trans-result-text'
text.innerHTML = trans
text.style.fontSize = '1rem'
text.style.padding = '5px'
text.style.color = 'white'
text.style.background = 'rgba(0, 0, 0, 0.4)'
div.append(text)
container.append(div)
}
}
text.style.display = trans ? 'block' : 'none'
}
}, false);
setInterval(() => {
refreshVideoInfo().catch(console.error)
refreshSubtitles()
}, 1000)

View File

@@ -0,0 +1,20 @@
import {getServerUrl} from '../util/biz_util'
export const handleChatCompleteTask = async (task: Task) => {
const data = task.def.data
const serverUrl = getServerUrl(task.def.serverUrl)
const resp = await fetch(`${serverUrl}/v1/chat/completions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + task.def.extra.apiKey,
},
body: JSON.stringify(data),
})
task.resp = await resp.json()
if (task.resp.usage) {
return (task.resp.usage.total_tokens??0) > 0
} else {
throw new Error(`${task.resp.error.code as string??''} ${task.resp.error.message as string ??''}`)
}
}

51
src/chrome/taskService.ts Normal file
View File

@@ -0,0 +1,51 @@
import {TASK_EXPIRE_TIME} from '../const'
import {handleChatCompleteTask} from './openaiService'
export const tasksMap = new Map<string, Task>()
export const handleTask = async (task: Task) => {
console.debug(`处理任务: ${task.id} (type: ${task.def.type})`)
try {
task.status = 'running'
switch (task.def.type) {
case 'chatComplete':
await handleChatCompleteTask(task)
break
default:
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new Error(`任务类型不支持: ${task.def.type}`)
}
console.debug(`处理任务成功: ${task.id} (type: ${task.def.type})`)
} catch (e: any) {
task.error = e.message
console.debug(`处理任务失败: ${task.id} (type: ${task.def.type})`, e.message)
}
task.status = 'done'
task.endTime = Date.now()
}
export const initTaskService = () => {
// 处理任务: tasksMap
setInterval(() => {
for (const [_, task] of tasksMap) {
if (task.status === 'pending') {
handleTask(task).catch(console.error)
break
} else if (task.status === 'running') {
break
}
}
}, 1000)
// 检测清理tasksMap
setInterval(() => {
const now = Date.now()
for (const [taskId, task] of tasksMap) {
if (task.startTime < now - TASK_EXPIRE_TIME) {
tasksMap.delete(taskId)
console.debug(`清理任务: ${task.id} (type: ${task.def.type})`)
}
}
}, 10000)
}

View File

@@ -0,0 +1,32 @@
.arrow, .arrow::before {
position: absolute;
width: 8px;
height: 8px;
background: inherit;
}
.arrow {
visibility: hidden;
}
.arrow::before {
visibility: visible;
content: '';
transform: rotate(45deg);
}
.tooltip[data-popper-placement^='top'] > .arrow {
bottom: -4px;
}
.tooltip[data-popper-placement^='bottom'] > .arrow {
top: -4px;
}
.tooltip[data-popper-placement^='left'] > .arrow {
right: -4px;
}
.tooltip[data-popper-placement^='right'] > .arrow {
left: -4px;
}

View File

@@ -0,0 +1,40 @@
import {PropsWithChildren, useState} from 'react'
import {Modifier, usePopper} from 'react-popper'
import popoverStyles from './Popover.module.less'
import * as PopperJS from '@popperjs/core'
import classNames from 'classnames'
interface Props extends PropsWithChildren {
/**
* 用于定位弹出框的元素
*/
refElement: Element | PopperJS.VirtualElement | null
className?: string | undefined
arrowClassName?: string | undefined
options?: Omit<Partial<PopperJS.Options>, 'modifiers'> & {
createPopper?: typeof PopperJS.createPopper
modifiers?: ReadonlyArray<Modifier<any>>
}
}
const Popover = (props: Props) => {
const {children, className, arrowClassName, refElement, options} = props
const [popperElement, setPopperElement] = useState<any>(null)
const [arrowElement, setArrowElement] = useState<any>(null)
const { styles, attributes } = usePopper<any>(refElement, popperElement, {
placement: 'top',
modifiers: [
{name: 'arrow', options: {element: arrowElement}},
{name: 'offset', options: {offset: [0, 8]}},
],
...options??{},
})
return <div className={classNames(popoverStyles.tooltip, className)} ref={setPopperElement} style={styles.popper} {...attributes.popper}>
<div className={classNames(popoverStyles.arrow, arrowClassName)} data-popper-arrow ref={setArrowElement} style={styles.arrow} />
{children}
</div>
}
export default Popover

76
src/const.tsx Normal file
View File

@@ -0,0 +1,76 @@
export const APP_DOM_ID = 'bilibili-subtitle'
export const STORAGE_ENV = 'bilibili-subtitle_env'
export const EVENT_EXPAND = 'expand'
export const TASK_EXPIRE_TIME = 15*60*1000
export const PAGE_MAIN = 'main'
export const PAGE_SETTINGS = 'settings'
export const TRANSLATE_COOLDOWN = 5*1000
export const TRANSLATE_FETCH_DEFAULT = 15
export const TRANSLATE_FETCH_MIN = 5
export const TRANSLATE_FETCH_MAX = 25
export const TRANSLATE_FETCH_STEP = 5
export const LANGUAGE_DEFAULT = 'en'
export const TOTAL_HEIGHT_MIN = 400
export const TOTAL_HEIGHT_DEF = 520
export const TOTAL_HEIGHT_MAX = 800
export const HEADER_HEIGHT = 44
export const TITLE_HEIGHT = 24
export const WORDS_DEFAULT = import.meta.env.VITE_ENV === 'web-dev'?500:2000
export const WORDS_MIN = 1000
export const WORDS_MAX = 3000
export const WORDS_STEP = 500
export const SUMMARIZE_THRESHOLD = 100
export const SUMMARIZE_LANGUAGE_DEFAULT = 'cn'
export const SUMMARIZE_ALL_THRESHOLD = 5
export const SERVER_URL_OPENAI = 'https://api.openai.com'
export const SERVER_URL_THIRD = 'https://op.kongkongye.com'
export const LANGUAGES = [{
code: 'en',
name: 'English',
}, {
code: 'ena',
name: 'American English',
}, {
code: 'enb',
name: 'British English',
}, {
code: 'cn',
name: '中文简体',
}, {
code: 'cnt',
name: '中文繁体',
}, {
code: 'Spanish',
name: 'español',
}, {
code: 'French',
name: 'Français',
}, {
code: 'Arabic',
name: 'العربية',
}, {
code: 'Russian',
name: 'русский',
}, {
code: 'German',
name: 'Deutsch',
}, {
code: 'Portuguese',
name: 'Português',
}, {
code: 'Italian',
name: 'Italiano',
}]
export const LANGUAGES_MAP: {[key: string]: typeof LANGUAGES[number]} = {}
for (const language of LANGUAGES) {
LANGUAGES_MAP[language.code] = language
}

864
src/data/data.json Normal file
View File

@@ -0,0 +1,864 @@
{
"font_size": 0.4,
"font_color": "#FFFFFF",
"background_alpha": 0.5,
"background_color": "#9C27B0",
"Stroke": "none",
"type": "AIsubtitle",
"lang": "zh",
"version": "a1.3.0.2",
"body": [
{
"from": 0.666,
"to": 1.799,
"location": 0,
"content": "各位听众朋友大家好"
},
{
"from": 1.8,
"to": 4.3,
"location": 0,
"content": "欢迎来到旧世代电台我是Lunmos"
},
{
"from": 4.4,
"to": 5.466,
"location": 0,
"content": "我们这期呢"
},
{
"from": 5.466,
"to": 6.966,
"location": 0,
"content": "来聊一个真正的"
},
{
"from": 6.966,
"to": 9.766,
"location": 0,
"content": "我觉得可能是新旧世代"
},
{
"from": 10.3,
"to": 12.333,
"location": 0,
"content": "分界线的这么一件事情"
},
{
"from": 12.733,
"to": 15.133,
"location": 0,
"content": "那首先呢还是来一个思维实验"
},
{
"from": 15.2,
"to": 17.5,
"location": 0,
"content": "上期其实我们也已经提到了"
},
{
"from": 17.5,
"to": 19.7,
"location": 0,
"content": "就是一个上世纪末的人"
},
{
"from": 19.7,
"to": 22.4,
"location": 0,
"content": "如果穿越到了2023年的现在"
},
{
"from": 22.733,
"to": 24.766,
"location": 0,
"content": "我们已经发展了20多年的"
},
{
"from": 24.8,
"to": 26.3,
"location": 0,
"content": "这么一个21世纪"
},
{
"from": 26.333,
"to": 27.766,
"location": 0,
"content": "就在此时此刻"
},
{
"from": 28,
"to": 28.8,
"location": 0,
"content": "那么"
},
{
"from": 29.7,
"to": 31.966,
"location": 0,
"content": "你问他他会被什么东西"
},
{
"from": 32.4,
"to": 34.533,
"location": 0,
"content": "所吓尿所吓到"
},
{
"from": 35.4,
"to": 35.933,
"location": 0,
"content": "那当然了"
},
{
"from": 35.933,
"to": 37.133,
"location": 0,
"content": "看过上期电台的朋友"
},
{
"from": 37.133,
"to": 39.099,
"location": 0,
"content": "已经知道我想说的那个答案了"
},
{
"from": 39.1,
"to": 41.3,
"location": 0,
"content": "不妨我们可以先来试试"
},
{
"from": 41.466,
"to": 42.966,
"location": 0,
"content": "感觉一下其他方面"
},
{
"from": 42.966,
"to": 45.599,
"location": 0,
"content": "我们有哪些地方发展的还算可以"
},
{
"from": 46.133,
"to": 47.599,
"location": 0,
"content": "比如说航空航天"
},
{
"from": 47.6,
"to": 51.066,
"location": 0,
"content": "他会被现在的航空航天科技所震惊吗"
},
{
"from": 51.066,
"to": 52.199,
"location": 0,
"content": "我觉得"
},
{
"from": 52.6,
"to": 53.366,
"location": 0,
"content": "不见得对吧"
},
{
"from": 53.366,
"to": 55.966,
"location": 0,
"content": "现在虽然说有了一些可回收的"
},
{
"from": 55.966,
"to": 57.499,
"location": 0,
"content": "航空航天的一些"
},
{
"from": 57.733,
"to": 59.599,
"location": 0,
"content": "科技虽然说有了一些"
},
{
"from": 59.6,
"to": 61,
"location": 0,
"content": "当时人们可能没太"
},
{
"from": 61,
"to": 62.466,
"location": 0,
"content": "见到的一些科技路线"
},
{
"from": 62.466,
"to": 63.266,
"location": 0,
"content": "但是"
},
{
"from": 63.966,
"to": 64.733,
"location": 0,
"content": "总体上来说"
},
{
"from": 64.733,
"to": 65.099,
"location": 0,
"content": "还是"
},
{
"from": 65.1,
"to": 67.8,
"location": 0,
"content": "现在的人被那个时候吓得还差不多"
},
{
"from": 67.8,
"to": 69.666,
"location": 0,
"content": "上个时期的美苏争霸时期"
},
{
"from": 69.666,
"to": 71.299,
"location": 0,
"content": "人们是有意愿在"
},
{
"from": 71.566,
"to": 73.866,
"location": 0,
"content": "航空航天的科技上去点科技术的"
},
{
"from": 73.866,
"to": 76.133,
"location": 0,
"content": "无论是为意志形态服务宣传也好"
},
{
"from": 76.133,
"to": 76.933,
"location": 0,
"content": "还是为了"
},
{
"from": 77.1,
"to": 78.9,
"location": 0,
"content": "真实可能存在的一种"
},
{
"from": 79.133,
"to": 79.666,
"location": 0,
"content": "更高的"
},
{
"from": 79.666,
"to": 82.666,
"location": 0,
"content": "所谓说更高维度的战争的形态也好"
},
{
"from": 82.866,
"to": 84.766,
"location": 0,
"content": "那么人类愿意去倾斜资源"
},
{
"from": 84.766,
"to": 86.966,
"location": 0,
"content": "去让那些在这些科技中"
},
{
"from": 87.466,
"to": 90.266,
"location": 0,
"content": "引领了发展进步的人成为英雄"
},
{
"from": 90.266,
"to": 91.399,
"location": 0,
"content": "那么这样"
},
{
"from": 91.4,
"to": 91.9,
"location": 0,
"content": "才能够"
},
{
"from": 91.9,
"to": 94.533,
"location": 0,
"content": "如愿以偿的得到他们想要的结果"
},
{
"from": 96.066,
"to": 97.466,
"location": 0,
"content": "好航空航天不太行"
},
{
"from": 97.466,
"to": 100.666,
"location": 0,
"content": "那么我们引以为豪的移动互联网"
},
{
"from": 100.666,
"to": 101.666,
"location": 0,
"content": "社交网络"
},
{
"from": 101.666,
"to": 103.366,
"location": 0,
"content": "这个你觉得可以吗"
},
{
"from": 103.366,
"to": 104.766,
"location": 0,
"content": "我觉得也不太行对吧"
},
{
"from": 104.766,
"to": 106.999,
"location": 0,
"content": "我们甚至很多现代人都觉得"
},
{
"from": 107.566,
"to": 108.699,
"location": 0,
"content": "从这些移动互联网"
},
{
"from": 108.7,
"to": 110.966,
"location": 0,
"content": "从这些社交网络中涉及到的东西"
},
{
"from": 110.966,
"to": 113.366,
"location": 0,
"content": "不一定是一个什么那么好的东西"
},
{
"from": 113.366,
"to": 114.466,
"location": 0,
"content": "那么他呢"
},
{
"from": 114.466,
"to": 116.133,
"location": 0,
"content": "当然也会觉得很新奇"
},
{
"from": 116.366,
"to": 118.266,
"location": 0,
"content": "但是他不会觉得这是一件"
},
{
"from": 118.7,
"to": 121.133,
"location": 0,
"content": "多么真正的了不起的事情"
},
{
"from": 121.133,
"to": 122.866,
"location": 0,
"content": "那无非就是把以前的摩尔定律"
},
{
"from": 122.866,
"to": 123.999,
"location": 0,
"content": "继续进行下去"
},
{
"from": 124,
"to": 126.266,
"location": 0,
"content": "然后不断的缩小计算机的体积"
},
{
"from": 126.866,
"to": 127.266,
"location": 0,
"content": "最终呢"
},
{
"from": 127.266,
"to": 129.533,
"location": 0,
"content": "总会能够实现的这么一件事对吧"
},
{
"from": 129.533,
"to": 130.733,
"location": 0,
"content": "手机能上网"
},
{
"from": 130.733,
"to": 132.266,
"location": 0,
"content": "无线网络如此之发达"
},
{
"from": 132.4,
"to": 134.466,
"location": 0,
"content": "也是一个基础设施的扩展"
},
{
"from": 134.466,
"to": 136.599,
"location": 0,
"content": "基础设施的扩张所能实现的事"
},
{
"from": 136.7,
"to": 139.166,
"location": 0,
"content": "那么人们其实并不会太折服于"
},
{
"from": 139.166,
"to": 140.899,
"location": 0,
"content": "使用大规模基础建设"
},
{
"from": 140.9,
"to": 143.166,
"location": 0,
"content": "就能够实现的某件事情"
},
{
"from": 143.166,
"to": 145.966,
"location": 0,
"content": "因为人们总会预期未来"
},
{
"from": 146.466,
"to": 147.333,
"location": 0,
"content": "未来的某一天"
},
{
"from": 147.333,
"to": 150.699,
"location": 0,
"content": "或慢或快的总会出现那么一个时代的"
},
{
"from": 150.7,
"to": 152.466,
"location": 0,
"content": "那就比如说大航海时代的人"
},
{
"from": 152.733,
"to": 153.799,
"location": 0,
"content": "他们也会想"
},
{
"from": 153.8,
"to": 156.5,
"location": 0,
"content": "未来的航路海运可能会非常的发达"
},
{
"from": 156.5,
"to": 158.733,
"location": 0,
"content": "那未来可能遍地都是像阿姆斯特丹"
},
{
"from": 158.733,
"to": 160.299,
"location": 0,
"content": "像伦敦这样的港口"
},
{
"from": 160.666,
"to": 161.799,
"location": 0,
"content": "虽然现在不是这样"
},
{
"from": 161.8,
"to": 162.166,
"location": 0,
"content": "但是"
},
{
"from": 162.166,
"to": 164.866,
"location": 0,
"content": "处在他们能想象的社会发展的极限"
},
{
"from": 165,
"to": 166.333,
"location": 0,
"content": "那我们也提到过游戏"
},
{
"from": 166.333,
"to": 168.533,
"location": 0,
"content": "但也就像有朋友在弹幕中提到的"
},
{
"from": 168.533,
"to": 170.599,
"location": 0,
"content": "想想时之笛是什么年代的游戏"
},
{
"from": 170.6,
"to": 171.3,
"location": 0,
"content": "那现在呢"
},
{
"from": 171.3,
"to": 174.066,
"location": 0,
"content": "甚至还在很多的游戏排行榜中"
},
{
"from": 174.066,
"to": 174.899,
"location": 0,
"content": "排在第一"
},
{
"from": 175.3,
"to": 176.8,
"location": 0,
"content": "那你想让那个时候的人"
},
{
"from": 176.8,
"to": 178.933,
"location": 0,
"content": "真正折服于现在的游戏呢"
},
{
"from": 179.466,
"to": 180.599,
"location": 0,
"content": "也没有那么容易对吧"
},
{
"from": 180.6,
"to": 181.7,
"location": 0,
"content": "在图形学上"
},
{
"from": 181.866,
"to": 182.399,
"location": 0,
"content": "我们也提到"
},
{
"from": 182.4,
"to": 183.3,
"location": 0,
"content": "图形学的发展"
},
{
"from": 183.3,
"to": 185.4,
"location": 0,
"content": "是不断把离线变成实时的过程"
},
{
"from": 185.666,
"to": 187.333,
"location": 0,
"content": "那么他们见过离线的东西"
},
{
"from": 187.333,
"to": 188.299,
"location": 0,
"content": "他们可以预测"
},
{
"from": 188.3,
"to": 191.4,
"location": 0,
"content": "这样的离线动画在将来会变成实时"
},
{
"from": 191.6,
"to": 193.166,
"location": 0,
"content": "那所以呢也不会觉得"
},
{
"from": 193.666,
"to": 194.899,
"location": 0,
"content": "超出认知对吧"
},
{
"from": 195.466,
"to": 196.933,
"location": 0,
"content": "那比如说生物医疗呢"
},
{
"from": 196.966,
"to": 198.899,
"location": 0,
"content": "那虽然说可以说有不少的进"
},
{
"from": 198.9,
"to": 199.566,
"location": 0,
"content": "展但还"
},
{
"from": 199.566,
"to": 201.733,
"location": 0,
"content": "是建立在去完善20世纪"
},
{
"from": 201.733,
"to": 203.066,
"location": 0,
"content": "这样一个科学大厦"
},
{
"from": 203.066,
"to": 204.566,
"location": 0,
"content": "生物大厦的基础上"
},
{
"from": 204.566,
"to": 205.866,
"location": 0,
"content": "对他进行了一些完善"
},
{
"from": 206.2,
"to": 208.166,
"location": 0,
"content": "你要说那些折磨着现代人的"
},
{
"from": 208.166,
"to": 209.399,
"location": 0,
"content": "各种各样的疾病"
},
{
"from": 209.466,
"to": 210.733,
"location": 0,
"content": "那个时候没有攻克的"
},
{
"from": 210.733,
"to": 213.366,
"location": 0,
"content": "现在有多少是被真正攻克了的呢"
},
{
"from": 213.366,
"to": 215.133,
"location": 0,
"content": "那其实也很少对吧"
},
{
"from": 215.266,
"to": 217.733,
"location": 0,
"content": "甚至可能我们连他的致病机理"
},
{
"from": 217.733,
"to": 219.499,
"location": 0,
"content": "还没有完全的搞清楚"
},
{
"from": 220.466,
"to": 221.699,
"location": 0,
"content": "其他可能还有一些比如"
},
{
"from": 221.933,
"to": 223.199,
"location": 0,
"content": "像可控核聚变"
},
{
"from": 223.2,
"to": 225.533,
"location": 0,
"content": "想更高效的电池技术这些"
},
{
"from": 226.3,
"to": 228.766,
"location": 0,
"content": "那就是目前可能还在发展的地方"
},
{
"from": 228.766,
"to": 229.899,
"location": 0,
"content": "怎么怎么说呢"
},
{
"from": 229.9,
"to": 230.733,
"location": 0,
"content": "就是到他"
},
{
"from": 230.733,
"to": 233.166,
"location": 0,
"content": "真正的出现了可以使用的成果之后呢"
},
{
"from": 233.166,
"to": 235.866,
"location": 0,
"content": "我们才能够去拿下来"
},
{
"from": 235.866,
"to": 238.533,
"location": 0,
"content": "让那些旧世代人的那些人"
},
{
"from": 238.533,
"to": 239.599,
"location": 0,
"content": "感到比较的震惊"
},
{
"from": 239.6,
"to": 239.9,
"location": 0,
"content": "但是"
},
{
"from": 239.9,
"to": 243.266,
"location": 0,
"content": "现在可能还没到那个完成的时间点吧"
},
{
"from": 244.4,
"to": 245.133,
"location": 0,
"content": "那么延续了"
},
{
"from": 245.133,
"to": 247.466,
"location": 0,
"content": "上个世纪的第三次科技革命中的"
},
{
"from": 247.466,
"to": 249.366,
"location": 0,
"content": "计算机软硬件的发展呢"
},
{
"from": 249.566,
"to": 251.599,
"location": 0,
"content": "其实惊喜也没有那么多"
},
{
"from": 251.6,
"to": 253.766,
"location": 0,
"content": "就上世纪的那些互联网应用"
},
{
"from": 253.9,
"to": 256.133,
"location": 0,
"content": "那很多时候其实已经挺好用了"
},
{
"from": 256.266,
"to": 257.733,
"location": 0,
"content": "论坛是上世纪的"
},
{
"from": 257.8,
"to": 260.133,
"location": 0,
"content": "那么i m软件是上世纪的"
},
{
"from": 260.133,
"to": 261.066,
"location": 0,
"content": "现在的互联网呢"
},
{
"from": 261.066,
"to": 264.766,
"location": 0,
"content": "是把他更多的做的是大规模是吧是吧"
},
{
"from": 264.766,
"to": 267.499,
"location": 0,
"content": "越来越多的人纳入到了这个体系中"
},
{
"from": 267.5,
"to": 269.566,
"location": 0,
"content": "所产生的这么一个规模效应"
},
{
"from": 269.933,
"to": 272.399,
"location": 0,
"content": "就当年操作系统能完成能做的事情呢"
}
]
}

22
src/data/keyPoints.json Normal file
View File

@@ -0,0 +1,22 @@
[
{
"time": "00:10",
"emoji": "👍",
"key": "人们喜欢短。"
},
{
"time": "01:00",
"emoji": "👍",
"key": "晶体智力和流体智力"
},
{
"time": "03:00",
"emoji": "👍",
"key": "外向成长是围绕外界展开的成长活动,以输出为输入可以带来正向反馈。"
},
{
"time": "05:00",
"emoji": "👍",
"key": "制定学习计划时,要专注打造自己的外向晶体和木子,即找到清晰的输出点,搜刮对应的项目和参考,借鉴晶体。"
}
]

6
src/hooks/redux.ts Normal file
View File

@@ -0,0 +1,6 @@
import {TypedUseSelectorHook, useDispatch, useSelector} from 'react-redux'
import type {AppDispatch, RootState} from '../store'
// Use throughout your app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

20
src/hooks/useSubtitle.ts Normal file
View File

@@ -0,0 +1,20 @@
import {useAppDispatch} from './redux'
import React, {useCallback} from 'react'
import {setNeedScroll} from '../redux/envReducer'
const useSubtitle = () => {
const dispatch = useAppDispatch()
const move = useCallback((time: number) => {
window.parent.postMessage({type: 'move', time}, '*')
}, [])
const scrollIntoView = useCallback((ref: React.RefObject<HTMLDivElement>) => {
ref.current?.scrollIntoView({behavior: 'smooth', block: 'center'})
dispatch(setNeedScroll(false))
}, [dispatch])
return {move, scrollIntoView}
}
export default useSubtitle

View File

@@ -0,0 +1,219 @@
import {useAppDispatch, useAppSelector} from './redux'
import {useContext, useEffect} from 'react'
import {
setCurFetched,
setCurIdx,
setCurInfo,
setCurrentTime,
setData,
setInfos,
setNoVideo,
setSegmentFold,
setSegments,
setTitle,
setTotalHeight,
} from '../redux/envReducer'
import {EventBusContext} from '../Router'
import {EVENT_EXPAND, TOTAL_HEIGHT_MAX, TOTAL_HEIGHT_MIN, WORDS_DEFAULT, WORDS_MAX, WORDS_MIN} from '../const'
import {useInterval} from 'ahooks'
import {getWholeText} from '../util/biz_util'
/**
* Service是单例类似后端的服务概念
*/
const useSubtitleService = () => {
const dispatch = useAppDispatch()
const infos = useAppSelector(state => state.env.infos)
const curInfo = useAppSelector(state => state.env.curInfo)
const curFetched = useAppSelector(state => state.env.curFetched)
const fold = useAppSelector(state => state.env.fold)
const envReady = useAppSelector(state => state.env.envReady)
const envData = useAppSelector(state => state.env.envData)
const data = useAppSelector(state => state.env.data)
const currentTime = useAppSelector(state => state.env.currentTime)
const curIdx = useAppSelector(state => state.env.curIdx)
const eventBus = useContext(EventBusContext)
const needScroll = useAppSelector(state => state.env.needScroll)
const segments = useAppSelector(state => state.env.segments)
const transResults = useAppSelector(state => state.env.transResults)
const hideOnDisableAutoTranslate = useAppSelector(state => state.env.envData.hideOnDisableAutoTranslate)
const autoTranslate = useAppSelector(state => state.env.autoTranslate)
// 设置屏安具
// 监听消息
useEffect(() => {
const listener = (event: MessageEvent) => {
const data = event.data
if (data.type === 'setVideoInfo') {
dispatch(setInfos(data.infos))
dispatch(setTitle(data.title))
console.debug('video title: ', data.title)
}
if (data.type === 'setInfos') {
dispatch(setInfos(data.infos))
dispatch(setCurInfo(undefined))
dispatch(setCurFetched(false))
dispatch(setData(undefined))
// console.log('setInfos', data.infos)
}
if (data.type === 'setSubtitle') {
const data_ = data.data.data
data_?.body?.forEach((item: TranscriptItem, idx: number) => {
item.idx = idx
})
// dispatch(setCurInfo(data.data.info))
dispatch(setCurFetched(true))
dispatch(setData(data_))
// console.log('setSubtitle', data.data)
}
if (data.type === 'setCurrentTime') {
dispatch(setCurrentTime(data.data.currentTime))
}
if (data.type === 'setSettings') {
dispatch(setNoVideo(data.data.noVideo))
if (data.data.totalHeight) {
dispatch(setTotalHeight(Math.min(Math.max(data.data.totalHeight, TOTAL_HEIGHT_MIN), TOTAL_HEIGHT_MAX)))
}
}
}
window.addEventListener('message', listener)
return () => {
window.removeEventListener('message', listener)
}
}, [dispatch, eventBus])
// 有数据时自动展开
useEffect(() => {
if ((data != null) && data.body.length > 0) {
eventBus.emit({
type: EVENT_EXPAND
})
}
}, [data, eventBus])
// 当前未展示 & (未折叠 | 自动展开) & 有列表 => 展示第一个
useEffect(() => {
if (!curInfo && (!fold || (envReady && envData.autoExpand)) && (infos != null) && infos.length > 0) {
dispatch(setCurInfo(infos[0]))
dispatch(setCurFetched(false))
}
}, [curInfo, dispatch, envData.autoExpand, envReady, fold, infos])
// 获取
useEffect(() => {
if (curInfo && !curFetched) {
window.parent.postMessage({type: 'getSubtitle', info: curInfo}, '*')
}
}, [curFetched, curInfo])
useEffect(() => {
// 初始获取列表
window.parent.postMessage({type: 'refreshVideoInfo'}, '*')
// 初始获取设置信息
window.parent.postMessage({type: 'getSettings'}, '*')
}, [])
// 更新当前位置
useEffect(() => {
let curIdx
if (((data?.body) != null) && currentTime) {
for (let i=0; i<data.body.length; i++) {
const item = data.body[i]
if (item.from && currentTime < item.from) {
break
} else {
curIdx = i
}
}
}
dispatch(setCurIdx(curIdx))
}, [currentTime, data?.body, dispatch])
// 需要滚动 => segment自动展开
useEffect(() => {
if (needScroll && curIdx != null) { // 需要滚动
for (const segment of segments??[]) { // 检测segments
if (segment.startIdx <= curIdx && curIdx <= segment.endIdx) { // 找到对应的segment
if (segment.fold) { // 需要展开
dispatch(setSegmentFold({
segmentStartIdx: segment.startIdx,
fold: false
}))
}
break
}
}
}
}, [curIdx, dispatch, needScroll, segments])
// data等变化时自动刷新segments
useEffect(() => {
let segments: Segment[] | undefined
const items = data?.body
if (items != null) {
if (envData.summarizeEnable) { // 分段
let size = envData.words??WORDS_DEFAULT
size = Math.min(Math.max(size, WORDS_MIN), WORDS_MAX)
segments = []
let transcriptItems: TranscriptItem[] = []
let totalLength = 0
for (let i = 0; i < items.length; i++) {
const item = items[i]
transcriptItems.push(item)
totalLength += item.content.length
if (totalLength >= size || i === items.length-1) { // new segment or last
// add
segments.push({
items: transcriptItems,
startIdx: transcriptItems[0].idx,
endIdx: transcriptItems[transcriptItems.length - 1].idx,
text: getWholeText(transcriptItems.map(item => item.content)),
summaries: {},
})
// reset
transcriptItems = []
totalLength = 0
}
}
} else { // 都放一个分段
segments = [{
items,
startIdx: 0,
endIdx: items.length-1,
text: getWholeText(items.map(item => item.content)),
summaries: {},
}]
}
}
dispatch(setSegments(segments))
}, [data?.body, dispatch, envData.summarizeEnable, envData.words])
// 每秒更新当前视频时间
useInterval(() => {
window.parent.postMessage({type: 'getCurrentTime'}, '*')
}, 500)
// show translated text in the video
useEffect(() => {
if (hideOnDisableAutoTranslate && !autoTranslate) {
window.parent.postMessage({type: 'updateTransResult'}, '*')
return
}
const transResult = curIdx?transResults[curIdx]:undefined
if (transResult?.code === '200' && transResult.data) {
window.parent.postMessage({type: 'updateTransResult', result: transResult.data}, '*')
} else {
window.parent.postMessage({type: 'updateTransResult'}, '*')
}
}, [autoTranslate, curIdx, hideOnDisableAutoTranslate, transResults])
}
export default useSubtitleService

310
src/hooks/useTranslate.ts Normal file
View File

@@ -0,0 +1,310 @@
import {useAppDispatch, useAppSelector} from './redux'
import {useCallback} from 'react'
import {
addTaskId,
addTransResults,
delTaskId,
setLastSummarizeTime,
setLastTransTime,
setSummaryContent,
setSummaryError,
setSummaryStatus
} from '../redux/envReducer'
import {
LANGUAGE_DEFAULT,
LANGUAGES_MAP,
SUMMARIZE_LANGUAGE_DEFAULT,
SUMMARIZE_THRESHOLD,
TRANSLATE_COOLDOWN,
TRANSLATE_FETCH_DEFAULT,
} from '../const'
import toast from 'react-hot-toast'
import {useMemoizedFn} from 'ahooks/es'
import {extractJsonArray, extractJsonObject} from '../util/biz_util'
import {formatTime} from '../util/util'
const useTranslate = () => {
const dispatch = useAppDispatch()
const data = useAppSelector(state => state.env.data)
const curIdx = useAppSelector(state => state.env.curIdx)
const lastTransTime = useAppSelector(state => state.env.lastTransTime)
const transResults = useAppSelector(state => state.env.transResults)
const envData = useAppSelector(state => state.env.envData)
const language = LANGUAGES_MAP[envData.language??LANGUAGE_DEFAULT]
const summarizeLanguage = LANGUAGES_MAP[envData.summarizeLanguage??SUMMARIZE_LANGUAGE_DEFAULT]
/**
* 获取下一个需要翻译的行
* 会检测冷却
*/
const getFetch = useCallback(() => {
if (data?.body != null && data.body.length > 0) {
const curIdx_ = curIdx ?? 0
// check lastTransTime
if (lastTransTime && Date.now() - lastTransTime < TRANSLATE_COOLDOWN) {
return
}
let nextIdleIdx
for (let i = curIdx_; i < data.body.length; i++) {
if (transResults[i] == null) {
nextIdleIdx = i
break
}
}
if (nextIdleIdx != null && nextIdleIdx - curIdx_ <= Math.ceil((envData.fetchAmount??TRANSLATE_FETCH_DEFAULT)/2)) {
return nextIdleIdx
}
}
}, [curIdx, data?.body, envData.fetchAmount, lastTransTime, transResults])
const addTask = useCallback(async (startIdx: number) => {
if ((data?.body) != null) {
const lines: string[] = data.body.slice(startIdx, startIdx + (envData.fetchAmount??TRANSLATE_FETCH_DEFAULT)).map((item: any) => item.content)
if (lines.length > 0) {
const linesMap: {[key: string]: string} = {}
lines.forEach((line, idx) => {
linesMap[(idx + 1)+''] = line
})
let lineStr = JSON.stringify(linesMap).replaceAll('\n', '')
lineStr = '```' + lineStr + '```'
const taskDef: TaskDef = {
type: 'chatComplete',
serverUrl: envData.serverUrl,
data: {
model: 'gpt-3.5-turbo',
messages: [
{
role: 'system',
content: 'You are a professional translator.'
},
{
role: 'user',
content: `Translate following video subtitles to language '${language.name}'.
Preserve incomplete sentence.
Translate in the same json format.
Answer in markdown json format.
video subtitles:
\`\`\`
${lineStr}
\`\`\``
}
],
temperature: 0,
n: 1,
stream: false,
},
extra: {
type: 'translate',
apiKey: envData.apiKey,
startIdx,
size: lines.length,
}
}
console.debug('addTask', taskDef)
dispatch(setLastTransTime(Date.now()))
// addTransResults
const result: { [key: number]: TransResult } = {}
lines.forEach((line, idx) => {
result[startIdx + idx] = {
// idx: startIdx + idx,
}
})
dispatch(addTransResults(result))
const task = await chrome.runtime.sendMessage({type: 'addTask', taskDef})
dispatch(addTaskId(task.id))
}
}
}, [data?.body, dispatch, envData.apiKey, envData.fetchAmount, envData.serverUrl, language.name])
const addSummarizeTask = useCallback(async (title: string | undefined, type: SummaryType, segment: Segment) => {
if (segment.text.length >= SUMMARIZE_THRESHOLD && envData.apiKey) {
const title_ = title?`The video's title is '${title}'.`:''
let subtitles = ''
for (const item of segment.items) {
subtitles += formatTime(item.from) + ' ' + item.content + '\n'
}
let content
if (type === 'overview') {
content = `You are a helpful assistant that summarize key points of video subtitle.
Summarize 3 to 8 brief key points in language '${summarizeLanguage.name}'.
Answer in markdown json format.
The emoji should be related to the key point and 1 char length.
example output format:
\`\`\`json
[
{
"time": "03:00",
"emoji": "👍",
"key": "key point 1"
},
{
"time": "10:05",
"emoji": "😊",
"key": "key point 2"
}
]
\`\`\`
The video's title: '''${title_}'''.
The video's subtitles:
'''
${subtitles}
'''`
} else if (type === 'keypoint') {
content = `You are a helpful assistant that summarize key points of video subtitle.
Summarize brief key points in language '${summarizeLanguage.name}'.
Answer in markdown json format.
example output format:
\`\`\`json
[
"key point 1",
"key point 2"
]
\`\`\`
The video's title: '''${title_}'''.
The video's subtitles:
'''
${segment.text}
'''`
} else if (type === 'brief') {
content = `You are a helpful assistant that summarize video subtitle.
Summarize in language '${summarizeLanguage.name}'.
Answer in markdown json format.
example output format:
\`\`\`json
{
"summary": "brief summary"
}
\`\`\`
The video's title: '''${title_}'''.
The video's subtitles:
'''
${segment.text}
'''`
}
const taskDef: TaskDef = {
type: 'chatComplete',
serverUrl: envData.serverUrl,
data: {
model: 'gpt-3.5-turbo',
messages: [
{
role: 'user',
content,
}
],
temperature: 0,
n: 1,
stream: false,
},
extra: {
type: 'summarize',
summaryType: type,
startIdx: segment.startIdx,
apiKey: envData.apiKey,
}
}
console.debug('addSummarizeTask', taskDef)
dispatch(setSummaryStatus({segmentStartIdx: segment.startIdx, type, status: 'pending'}))
dispatch(setLastSummarizeTime(Date.now()))
const task = await chrome.runtime.sendMessage({type: 'addTask', taskDef})
dispatch(addTaskId(task.id))
}
}, [dispatch, envData.apiKey, envData.serverUrl, summarizeLanguage.name])
const handleTranslate = useMemoizedFn((task: Task, content: string) => {
let map: {[key: string]: string} = {}
try {
content = extractJsonObject(content)
map = JSON.parse(content)
} catch (e) {
console.debug(e)
}
const {startIdx, size} = task.def.extra
if (startIdx != null) {
const result: { [key: number]: TransResult } = {}
for (let i = 0; i < size; i++) {
const item = map[(i + 1)+'']
if (item) {
result[startIdx + i] = {
// idx: startIdx + i,
code: '200',
data: item,
}
} else {
result[startIdx + i] = {
// idx: startIdx + i,
code: '500',
}
}
}
dispatch(addTransResults(result))
console.debug('addTransResults', map, size)
}
})
const handleSummarize = useMemoizedFn((task: Task, content?: string) => {
const summaryType = task.def.extra.summaryType
content = summaryType === 'brief'?extractJsonObject(content??''):extractJsonArray(content??'')
let obj
try {
obj = JSON.parse(content)
} catch (e) {
task.error = 'failed'
}
dispatch(setSummaryContent({
segmentStartIdx: task.def.extra.startIdx,
type: summaryType,
content: obj,
}))
dispatch(setSummaryStatus({segmentStartIdx: task.def.extra.startIdx, type: summaryType, status: 'done'}))
dispatch(setSummaryError({segmentStartIdx: task.def.extra.startIdx, type: summaryType, error: task.error}))
console.debug('setSummary', task.def.extra.startIdx, summaryType, obj, task.error)
})
const getTask = useCallback(async (taskId: string) => {
const taskResp = await chrome.runtime.sendMessage({type: 'getTask', taskId})
if (taskResp.code === 'ok') {
console.debug('getTask', taskResp.task)
const task: Task = taskResp.task
const taskType: string | undefined = task.def.extra?.type
const content = task.resp?.choices?.[0]?.message?.content?.trim()
if (task.status === 'done') {
// 异常提示
if (task.error) {
toast.error(task.error)
}
// 删除任务
dispatch(delTaskId(taskId))
// 处理结果
if (taskType === 'translate') { // 翻译
handleTranslate(task, content)
} else if (taskType === 'summarize') { // 总结
handleSummarize(task, content)
}
}
} else {
dispatch(delTaskId(taskId))
}
}, [dispatch, handleSummarize, handleTranslate])
return {getFetch, getTask, addTask, addSummarizeTask}
}
export default useTranslate

View File

@@ -0,0 +1,55 @@
import {useAppDispatch, useAppSelector} from './redux'
import {useEffect} from 'react'
import {clearTransResults} from '../redux/envReducer'
import {useInterval, useMemoizedFn} from 'ahooks'
import useTranslate from './useTranslate'
/**
* Service是单例类似后端的服务概念
*/
const useTranslateService = () => {
const dispatch = useAppDispatch()
const autoTranslate = useAppSelector(state => state.env.autoTranslate)
const data = useAppSelector(state => state.env.data)
const taskIds = useAppSelector(state => state.env.taskIds)
const curIdx = useAppSelector(state => state.env.curIdx)
const {getFetch, addTask, getTask} = useTranslate()
// data变化时清空翻译结果
useEffect(() => {
dispatch(clearTransResults())
console.debug('清空翻译结果')
}, [data, dispatch])
// autoTranslate开启时立即查询
const addTaskNow = useMemoizedFn(() => {
addTask(curIdx??0).catch(console.error)
})
useEffect(() => {
if (autoTranslate) {
addTaskNow()
console.debug('立即查询翻译')
}
}, [autoTranslate, addTaskNow])
// 每3秒检测翻译
useInterval(async () => {
if (autoTranslate) {
const fetchStartIdx = getFetch()
if (fetchStartIdx != null) {
await addTask(fetchStartIdx)
}
}
}, 3000)
// 每0.5秒检测获取结果
useInterval(async () => {
if (taskIds != null) {
for (const taskId of taskIds) {
await getTask(taskId)
}
}
}, 500)
}
export default useTranslateService

28
src/index.less Normal file
View File

@@ -0,0 +1,28 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-size: 16px;
}
body {
font-size: 100%;
}
#bilibili-subtitle {
font-family: PingFang SC, HarmonyOS_Regular, Helvetica Neue, Microsoft YaHei, sans-serif;
font-size: 16px;
text-align: left;
}
.desc {
@apply text-base-content/80;
}
.desc-lighter {
@apply text-base-content/60;
}
.flex-center {
@apply flex items-center;
}

22
src/main.tsx Normal file
View File

@@ -0,0 +1,22 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import './index.less'
import store from './store'
import {Provider} from 'react-redux'
import Router from './Router'
import {APP_DOM_ID} from './const'
const body = document.querySelector('body')
const app = document.createElement('div')
app.id = APP_DOM_ID
if (body != null) {
body.prepend(app)
}
ReactDOM.createRoot(document.getElementById(APP_DOM_ID) as HTMLElement).render(
<React.StrictMode>
<Provider store={store}>
<Router/>
</Provider>
</React.StrictMode>
)

246
src/redux/envReducer.ts Normal file
View File

@@ -0,0 +1,246 @@
import {createSlice, PayloadAction} from '@reduxjs/toolkit'
import {find} from 'lodash-es'
import {getDevData} from '../util/biz_util'
import {SERVER_URL_OPENAI, TOTAL_HEIGHT_DEF} from '../const'
interface EnvState {
envData: EnvData
envReady: boolean
fold: boolean // fold app
foldAll?: boolean // fold all segments
page?: string
autoTranslate?: boolean
autoScroll?: boolean
checkAutoScroll?: boolean
curOffsetTop?: number
compact?: boolean // 是否紧凑视图
floatKeyPointsSegIdx?: number // segment的startIdx
noVideo?: boolean
totalHeight: number
curIdx?: number // 从0开始
needScroll?: boolean
currentTime?: number
downloadType?: string
infos?: any[]
curInfo?: any
curFetched?: boolean
data?: Transcript
uploadedTranscript?: Transcript
segments?: Segment[]
title?: string
curSummaryType: SummaryType
taskIds?: string[]
transResults: {[key: number]: TransResult}
lastTransTime?: number
lastSummarizeTime?: number
}
const initialState: EnvState = {
envData: {
serverUrl: SERVER_URL_OPENAI,
translateEnable: true,
summarizeEnable: true,
theme: 'light',
},
totalHeight: TOTAL_HEIGHT_DEF,
autoScroll: true,
currentTime: import.meta.env.VITE_ENV === 'web-dev'? 30: undefined,
envReady: false,
fold: true,
data: import.meta.env.VITE_ENV === 'web-dev'? getDevData(): undefined,
transResults: {},
curSummaryType: 'overview',
}
export const slice = createSlice({
name: 'env',
initialState,
reducers: {
setEnvData: (state, action: PayloadAction<EnvData>) => {
state.envData = {
...state.envData,
...action.payload,
}
},
setEnvReady: (state) => {
state.envReady = true
},
setFloatKeyPointsSegIdx: (state, action: PayloadAction<number | undefined>) => {
state.floatKeyPointsSegIdx = action.payload
},
setFoldAll: (state, action: PayloadAction<boolean>) => {
state.foldAll = action.payload
},
setCurSummaryType: (state, action: PayloadAction<SummaryType>) => {
state.curSummaryType = action.payload
},
setCompact: (state, action: PayloadAction<boolean>) => {
state.compact = action.payload
},
setPage: (state, action: PayloadAction<string | undefined>) => {
state.page = action.payload
},
setTotalHeight: (state, action: PayloadAction<number>) => {
state.totalHeight = action.payload
},
setTaskIds: (state, action: PayloadAction<string[]>) => {
state.taskIds = action.payload
},
setLastTransTime: (state, action: PayloadAction<number>) => {
state.lastTransTime = action.payload
},
setLastSummarizeTime: (state, action: PayloadAction<number>) => {
state.lastSummarizeTime = action.payload
},
addTaskId: (state, action: PayloadAction<string>) => {
state.taskIds = [...(state.taskIds??[]), action.payload]
},
delTaskId: (state, action: PayloadAction<string>) => {
state.taskIds = state.taskIds?.filter(id => id !== action.payload)
},
addTransResults: (state, action: PayloadAction<{[key: number]: TransResult}>) => {
// 不要覆盖TransResult里code为200的
for (const payloadKey in action.payload) {
const payloadItem = action.payload[payloadKey]
const stateItem = state.transResults[payloadKey]
if (!stateItem || stateItem.code !== '200') {
state.transResults[payloadKey] = payloadItem
} else if (stateItem.code === '200') { // 保留data
state.transResults[payloadKey] = {
...payloadItem,
data: stateItem.data,
}
}
}
},
setSummaryContent: (state, action: PayloadAction<{
segmentStartIdx: number
type: SummaryType
content?: any
}>) => {
const segment = find(state.segments, {startIdx: action.payload.segmentStartIdx})
if (segment != null) {
let summary = segment.summaries[action.payload.type]
if (!summary) {
summary = {
type: action.payload.type,
status: 'done',
content: action.payload.content,
}
segment.summaries[action.payload.type] = summary
} else {
summary.content = action.payload.content
}
}
},
setSummaryStatus: (state, action: PayloadAction<{
segmentStartIdx: number
type: SummaryType
status: SummaryStatus
}>) => {
const segment = find(state.segments, {startIdx: action.payload.segmentStartIdx})
if (segment != null) {
let summary = segment.summaries[action.payload.type]
if (summary) {
summary.status = action.payload.status
} else {
summary = {
type: action.payload.type,
status: action.payload.status,
}
segment.summaries[action.payload.type] = summary
}
}
},
setSummaryError: (state, action: PayloadAction<{
segmentStartIdx: number
type: SummaryType
error?: string
}>) => {
const segment = find(state.segments, {startIdx: action.payload.segmentStartIdx})
if (segment != null) {
let summary = segment.summaries[action.payload.type]
if (summary) {
summary.error = action.payload.error
} else {
summary = {
type: action.payload.type,
status: 'done',
error: action.payload.error,
}
segment.summaries[action.payload.type] = summary
}
}
},
setSegmentFold: (state, action: PayloadAction<{
segmentStartIdx: number
fold: boolean
}>) => {
const segment = find(state.segments, {startIdx: action.payload.segmentStartIdx})
if (segment != null) {
segment.fold = action.payload.fold
}
},
clearTransResults: (state) => {
state.transResults = {}
},
setCurIdx: (state, action: PayloadAction<number | undefined>) => {
state.curIdx = action.payload
},
setAutoTranslate: (state, action: PayloadAction<boolean>) => {
state.autoTranslate = action.payload
},
setAutoScroll: (state, action: PayloadAction<boolean>) => {
state.autoScroll = action.payload
},
setCheckAutoScroll: (state, action: PayloadAction<boolean>) => {
state.checkAutoScroll = action.payload
},
setCurOffsetTop: (state, action: PayloadAction<number | undefined>) => {
state.curOffsetTop = action.payload
},
setNoVideo: (state, action: PayloadAction<boolean>) => {
state.noVideo = action.payload
},
setDownloadType: (state, action: PayloadAction<string>) => {
state.downloadType = action.payload
},
setNeedScroll: (state, action: PayloadAction<boolean>) => {
state.needScroll = action.payload
},
setCurrentTime: (state, action: PayloadAction<number | undefined>) => {
state.currentTime = action.payload
},
setTitle: (state, action: PayloadAction<string | undefined>) => {
state.title = action.payload
},
setInfos: (state, action: PayloadAction<any[]>) => {
state.infos = action.payload
},
setCurInfo: (state, action: PayloadAction<any>) => {
state.curInfo = action.payload
},
setCurFetched: (state, action: PayloadAction<boolean>) => {
state.curFetched = action.payload
},
setData: (state, action: PayloadAction<Transcript | undefined>) => {
state.data = action.payload
},
setUploadedTranscript: (state, action: PayloadAction<Transcript | undefined>) => {
state.uploadedTranscript = action.payload
},
setSegments: (state, action: PayloadAction<Segment[] | undefined>) => {
state.segments = action.payload
},
setFold: (state, action: PayloadAction<boolean>) => {
state.fold = action.payload
},
},
})
export const { setCurSummaryType, setUploadedTranscript, setTotalHeight, setCheckAutoScroll, setCurOffsetTop, setFloatKeyPointsSegIdx, setFoldAll, setCompact, setSegmentFold, setSummaryContent, setSummaryStatus, setSummaryError, setTitle, setSegments, setLastSummarizeTime, setPage, setLastTransTime, clearTransResults, addTransResults, addTaskId, delTaskId, setTaskIds, setDownloadType, setAutoTranslate, setAutoScroll, setNoVideo, setNeedScroll, setCurIdx, setEnvData, setEnvReady, setCurrentTime, setInfos, setCurInfo, setCurFetched, setData, setFold } = slice.actions
export default slice.reducer

14
src/store.ts Normal file
View File

@@ -0,0 +1,14 @@
import {configureStore} from '@reduxjs/toolkit'
import envReducer from './redux/envReducer'
const store = configureStore({
reducer: {
env: envReducer,
},
})
export default store
// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>
// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState}
export type AppDispatch = typeof store.dispatch

106
src/typings.d.ts vendored Normal file
View File

@@ -0,0 +1,106 @@
interface EnvData {
autoExpand?: boolean
flagDot?: boolean
apiKey?: string
serverUrl?: string
translateEnable?: boolean
language?: string
hideOnDisableAutoTranslate?: boolean
transDisplay?: 'target' | 'originPrimary' | 'targetPrimary'
fetchAmount?: number
summarizeEnable?: boolean
summarizeLanguage?: string
words?: number
summarizeFloat?: boolean
theme?: 'system' | 'light' | 'dark'
fontSize?: 'normal' | 'large'
}
interface TaskDef {
type: 'chatComplete'
serverUrl?: string
data: any
extra?: any
}
interface Task {
id: string
startTime: number
endTime?: number
def: TaskDef
status: 'pending' | 'running' | 'done'
error?: string
resp?: any
}
interface TransResult {
// idx: number
code?: '200' | '500'
data?: string
}
type ShowElement = string | JSX.Element | undefined
interface Transcript {
body: TranscriptItem[]
}
interface TranscriptItem {
from: number
to: number
content: string
idx: number
}
interface Segment {
items: TranscriptItem[]
startIdx: number // 从1开始
endIdx: number
text: string
fold?: boolean
summaries: {
[type: string]: Summary
}
}
interface OverviewItem {
time: string
emoji: string
key: string
}
interface Summary {
type: SummaryType
status: SummaryStatus
error?: string
content?: any
}
/**
* 概览
*/
interface OverviewSummary extends Summary {
content?: OverviewItem[]
}
/**
* 要点
*/
interface KeypointSummary extends Summary {
content?: string[]
}
/**
* 总结
*/
interface BriefSummary extends Summary {
content?: {
summary: string
}
}
type SummaryStatus = 'init' | 'pending' | 'done'
type SummaryType = 'overview' | 'keypoint' | 'brief'

277
src/util/biz_util.ts Normal file
View File

@@ -0,0 +1,277 @@
import devData from '../data/data.json'
import {APP_DOM_ID} from '../const'
import {isDarkMode} from '@kky002/kky-util'
import toast from 'react-hot-toast'
import {findIndex} from 'lodash-es'
/**
* 获取译文
*/
export const getTransText = (transResult: TransResult, hideOnDisableAutoTranslate: boolean | undefined, autoTranslate: boolean | undefined) => {
if (transResult && (!transResult.code || transResult.code === '200') && (autoTranslate === true || !hideOnDisableAutoTranslate) && transResult.data) {
return transResult.data
}
}
export const getDisplay = (transDisplay_: EnvData['transDisplay'], content: string, transText: string | undefined) => {
const transDisplay = transDisplay_ ?? 'originPrimary'
let main, sub
// main
if (transText && (transDisplay === 'targetPrimary' || transDisplay === 'target')) {
main = transText
} else {
main = content
}
// sub
switch (transDisplay) {
case 'originPrimary':
sub = transText
break
case 'targetPrimary':
if (transText) {
sub = content
}
break
default:
break
}
// return
return {
main,
sub,
}
}
export const getWholeText = (items: string[]) => {
return items.join(',').replaceAll('\n', ' ')
}
export const getLastTime = (seconds: number) => {
if (seconds > 60 * 60) {
return `${Math.floor(seconds / 60 / 60)}小时`
}
if (seconds > 60) {
return `${Math.floor(seconds / 60)}分钟`
}
return `${Math.floor(seconds)}`
}
/**
* 00:00:00
*/
export const getTimeDisplay = (seconds: number) => {
const h = Math.floor(seconds / 60 / 60)
const m = Math.floor(seconds / 60 % 60)
const s = Math.floor(seconds % 60)
return `${h < 10 ? '0' : ''}${h}:${m < 10 ? '0' : ''}${m}:${s < 10 ? '0' : ''}${s}`
}
export const getDevData = () => {
// add idx
const body = devData.body.map((item, idx) => ({
...item,
idx,
}))
return {
...devData,
body,
}
}
export const isSummaryEmpty = (summary: Summary) => {
if (summary.type === 'overview') {
const content: OverviewItem[] = summary.content??[]
return content.length === 0
} else if (summary.type === 'keypoint') {
const content: string[] = summary.content??[]
return content.length === 0
} else if (summary.type === 'brief') {
const content: string[] = summary.content??''
return content.length === 0
}
return true
}
export const getSummaryStr = (summary: Summary) => {
let s = ''
if (summary.type === 'overview') {
const content: OverviewItem[] = summary.content ?? []
for (const overviewItem of content) {
s += (overviewItem.emoji ?? '') + overviewItem.time + ' ' + overviewItem.key + '\n'
}
} else if (summary.type === 'keypoint') {
const content: string[] = summary.content ?? []
for (const keypoint of content) {
s += '- ' + keypoint + '\n'
}
} else if (summary.type === 'brief') {
const content: { summary: string } = summary.content ?? {
summary: ''
}
s += content.summary
}
return s
}
export const getServerUrl = (serverUrl?: string) => {
if (!serverUrl) {
return 'https://api.openai.com'
}
if (serverUrl.endsWith('/')) {
serverUrl = serverUrl.slice(0, -1)
}
return serverUrl
}
export const setTheme = (theme: EnvData['theme']) => {
const appRoot = document.getElementById(APP_DOM_ID)
if (appRoot != null) {
// system
theme = theme ?? 'system'
if (!theme || theme === 'system') {
theme = isDarkMode() ? 'dark' : 'light'
}
appRoot.setAttribute('data-theme', theme)
if (theme === 'dark') {
appRoot.classList.add('dark')
appRoot.classList.remove('light')
} else {
appRoot.classList.add('light')
appRoot.classList.remove('dark')
}
}
}
export const getSummarize = (title: string | undefined, segments: Segment[] | undefined, type: SummaryType): [boolean, string] => {
if (segments == null) {
return [false, '']
}
let content = `${title ?? ''}\n\n`
let success = false
for (const segment of segments) {
if (segment.items.length > 0) {
content += `${getTimeDisplay(segment.items[0].from)}\n`
}
const summary = segment.summaries[type]
if (summary && !isSummaryEmpty(summary)) {
success = true
content += getSummaryStr(summary)
} else {
content += '无总结\n'
}
content += '\n'
}
if (!success) {
toast.error('未找到总结')
}
return [success, content]
}
/**
* @param time '03:10'
*/
export const parseStrTimeToSeconds = (time: string): number => {
const parts = time.split(':')
return parseInt(parts[0]) * 60 + parseInt(parts[1])
}
/**
* @param time '00:04:11,599' or '00:04:11.599' or '04:11,599' or '04:11.599'
* @return seconds, 4.599
*/
export const parseTime = (time: string): number => {
const separator = time.includes(',') ? ',' : '.'
const parts = time.split(':')
const ms = parts[parts.length-1].split(separator)
if (parts.length === 3) {
return parseInt(parts[0]) * 60 * 60 + parseInt(parts[1]) * 60 + parseInt(ms[0]) + parseInt(ms[1]) / 1000
} else {
return parseInt(parts[0]) * 60 + parseInt(ms[0]) + parseInt(ms[1]) / 1000
}
}
export const parseTranscript = (filename: string, text: string | ArrayBuffer): Transcript => {
const items: TranscriptItem[] = []
// convert /r/n to /n
text = (text as string).trim().replace(/\r\n/g, '\n')
// .srt:
if (filename.toLowerCase().endsWith('.srt')) {
const lines = text.split('\n\n')
for (const line of lines) {
const lines = line.split('\n')
if (lines.length >= 3) {
const time = lines[1].split(' --> ')
const from = parseTime(time[0])
const to = parseTime(time[1])
const content = lines.slice(2).join('\n')
items.push({
from,
to,
content,
idx: items.length,
})
}
}
}
// .vtt:
if (filename.toLowerCase().endsWith('.vtt')) {
const lines = text.split('\n\n')
for (const line of lines) {
const lines = line.split('\n')
const timeIdx = findIndex(lines, (line) => line.includes('-->'))
if (timeIdx >= 0) {
const time = lines[timeIdx].split(' --> ')
const from = parseTime(time[0])
const to = parseTime(time[1])
const content = lines.slice(timeIdx + 1).join('\n')
items.push({
from,
to,
content,
idx: items.length,
})
}
}
}
// return
return {
body: items,
}
}
export const extractJsonObject = (content: string) => {
// get content between ``` and ```
const start = content.indexOf('```')
const end = content.lastIndexOf('```')
if (start >= 0 && end >= 0) {
content = content.slice(start + 3, end)
}
// get content between { and }
const start2 = content.indexOf('{')
const end2 = content.lastIndexOf('}')
if (start2 >= 0 && end2 >= 0) {
content = content.slice(start2, end2 + 1)
}
return content
}
export const extractJsonArray = (content: string) => {
// get content between ``` and ```
const start = content.indexOf('```')
const end = content.lastIndexOf('```')
if (start >= 0 && end >= 0) {
content = content.slice(start + 3, end)
}
// get content between [ and ]
const start3 = content.indexOf('[')
const end3 = content.lastIndexOf(']')
if (start3 >= 0 && end3 >= 0) {
content = content.slice(start3, end3 + 1)
}
return content
}

43
src/util/util.ts Normal file
View File

@@ -0,0 +1,43 @@
import {SyntheticEvent} from 'react'
export const formatTime = (time: number) => {
if (!time) return '00:00'
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}
/**
* @param time 2.82
*/
export const formatSrtTime = (time: number) => {
if (!time) return '00:00:00,000'
const hours = Math.floor(time / 60 / 60)
const minutes = Math.floor(time / 60 % 60)
const seconds = Math.floor(time % 60)
const ms = Math.floor((time % 1) * 1000)
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')},${ms.toString().padStart(3, '0')}`
}
/**
* @param time 2.82
*/
export const formatVttTime = (time: number) => {
if (!time) return '00:00:00.000'
const hours = Math.floor(time / 60 / 60)
const minutes = Math.floor(time / 60 % 60)
const seconds = Math.floor(time % 60)
const ms = Math.floor((time % 1) * 1000)
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${ms.toString().padStart(3, '0')}`
}
export const preventFunc = (e: SyntheticEvent) => {
e.preventDefault()
}
export const stopPopFunc = (e: SyntheticEvent) => {
e.stopPropagation()
}

8
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

35
tailwind.config.cjs Normal file
View File

@@ -0,0 +1,35 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: 'class',
content: ["./src/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [
require('tailwind-scrollbar-hide'),
require('@tailwindcss/line-clamp'),
require('@tailwindcss/typography'),
require('daisyui'),
],
daisyui: {
styled: true,
themes: [{
light: {
...require("daisyui/src/colors/themes")["[data-theme=light]"],
"--rounded-btn": "0.15rem",
},
}, {
dark: {
...require("daisyui/src/colors/themes")["[data-theme=dark]"],
"--rounded-btn": "0.15rem",
}
}],
base: true,
utils: true,
logs: true,
rtl: false,
prefix: "",
darkTheme: "dark",
},
}

24
tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"types": [
"@types/chrome"
]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"module": "ESNext",
"moduleResolution": "Node",
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true
},
"include": ["vite.config.ts", "manifest.json"]
}

29
vite.config.ts Normal file
View File

@@ -0,0 +1,29 @@
import {defineConfig, PluginOption} from 'vite'
import react from '@vitejs/plugin-react'
import {visualizer} from "rollup-plugin-visualizer";
import {crx} from '@crxjs/vite-plugin'
// @ts-ignore
import manifest from './manifest.json'
// https://vitejs.dev/config/
export default ({mode}) => {
const plugins = [
react(),
visualizer() as PluginOption,
]
// @ts-ignore
if (mode === 'production_chrome') {
plugins.push(crx({
manifest,
}))
}
return defineConfig({
base: '/',
plugins,
css: {
modules: {
localsConvention: "camelCase"
}
}
})
}