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

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