You've already forked bilibili-subtitle
fix
This commit is contained in:
70
src/components/Ask.tsx
Normal file
70
src/components/Ask.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import {AiOutlineCloseCircle, BsDashSquare, BsPlusSquare, FaQuestion} from 'react-icons/all'
|
||||
import classNames from 'classnames'
|
||||
import Markdown from '../components/Markdown'
|
||||
import React, {useCallback} from 'react'
|
||||
import {delAskInfo, mergeAskInfo, setTempData} from '../redux/envReducer'
|
||||
import {useAppDispatch, useAppSelector} from '../hooks/redux'
|
||||
import toast from 'react-hot-toast'
|
||||
import useTranslate from '../hooks/useTranslate'
|
||||
|
||||
const Ask = (props: {
|
||||
ask: AskInfo
|
||||
}) => {
|
||||
const {ask} = props
|
||||
const dispatch = useAppDispatch()
|
||||
const envData = useAppSelector(state => state.env.envData)
|
||||
const fontSize = useAppSelector(state => state.env.envData.fontSize)
|
||||
const segments = useAppSelector(state => state.env.segments)
|
||||
const {addAskTask} = useTranslate()
|
||||
|
||||
const onRegenerate = useCallback(() => {
|
||||
const apiKey = envData.aiType === 'gemini'?envData.geminiApiKey:envData.apiKey
|
||||
if (apiKey) {
|
||||
if (segments != null && segments.length > 0) {
|
||||
addAskTask(ask.id, segments[0], ask.question).catch(console.error)
|
||||
}
|
||||
} else {
|
||||
toast.error('请先在选项页面设置ApiKey!')
|
||||
}
|
||||
}, [addAskTask, ask.id, ask.question, envData.aiType, envData.apiKey, envData.geminiApiKey, segments])
|
||||
|
||||
const onAskFold = useCallback(() => {
|
||||
dispatch(mergeAskInfo({
|
||||
id: ask.id,
|
||||
fold: !ask.fold
|
||||
}))
|
||||
}, [ask, dispatch])
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
dispatch(delAskInfo(ask.id))
|
||||
}, [ask, dispatch])
|
||||
|
||||
return <div className='shadow bg-base-200 my-0.5 mx-1.5 p-1.5 rounded flex flex-col justify-center items-center'>
|
||||
<div className='w-full relative flex justify-center min-h-[20px]'>
|
||||
<div className='absolute left-0 top-0 bottom-0 text-xs select-none flex-center desc'>
|
||||
{ask.fold
|
||||
? <BsPlusSquare className='cursor-pointer' onClick={onAskFold}/> :
|
||||
<BsDashSquare className='cursor-pointer' onClick={onAskFold}/>}
|
||||
</div>
|
||||
<button className='absolute right-0 top-0 bottom-0 btn btn-ghost btn-xs btn-circle text-base-content/75' onClick={onClose}>
|
||||
<AiOutlineCloseCircle/>
|
||||
</button>
|
||||
<div className="tabs">
|
||||
<a className="tab tab-lifted tab-xs tab-disabled cursor-default"></a>
|
||||
<a className='tab tab-lifted tab-xs tab-active'><FaQuestion/>提问</a>
|
||||
<a className="tab tab-lifted tab-xs tab-disabled cursor-default"></a>
|
||||
</div>
|
||||
</div>
|
||||
{!ask.fold && ask.question && <div className='text-sm font-medium max-w-[90%]'>{ask.question}</div>}
|
||||
{!ask.fold && ask.content &&
|
||||
<div className={classNames('font-medium max-w-[90%] mt-1', fontSize === 'large' ? 'text-sm' : 'text-xs')}>
|
||||
<Markdown content={ask.content}/>
|
||||
</div>}
|
||||
{!ask.fold && <button disabled={ask.status !== 'done'}
|
||||
className={classNames('btn btn-link btn-xs', ask.status === 'pending' && 'loading')}
|
||||
onClick={onRegenerate}>{ask.status === 'pending' ? '生成中' : '重新生成'}</button>}
|
||||
{!ask.fold && ask.error && <div className='text-xs text-error'>{ask.error}</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
export default Ask
|
415
src/components/Body.tsx
Normal file
415
src/components/Body.tsx
Normal file
@@ -0,0 +1,415 @@
|
||||
import React, {useCallback, useEffect, useMemo, useRef} from 'react'
|
||||
import {
|
||||
addAskInfo,
|
||||
mergeAskInfo,
|
||||
setAutoScroll,
|
||||
setAutoTranslate,
|
||||
setCheckAutoScroll,
|
||||
setFoldAll,
|
||||
setNeedScroll,
|
||||
setSearchText,
|
||||
setSegmentFold,
|
||||
setTempData
|
||||
} from '../redux/envReducer'
|
||||
import {useAppDispatch, useAppSelector} from '../hooks/redux'
|
||||
import {
|
||||
AiOutlineAim,
|
||||
AiOutlineCloseCircle,
|
||||
FaRegArrowAltCircleDown,
|
||||
IoWarning,
|
||||
MdExpand,
|
||||
RiTranslate
|
||||
} from 'react-icons/all'
|
||||
import classNames from 'classnames'
|
||||
import toast from 'react-hot-toast'
|
||||
import SegmentCard from './SegmentCard'
|
||||
import {
|
||||
ASK_ENABLED_DEFAULT,
|
||||
HEADER_HEIGHT,
|
||||
PAGE_SETTINGS,
|
||||
SEARCH_BAR_HEIGHT,
|
||||
SUMMARIZE_ALL_THRESHOLD,
|
||||
TITLE_HEIGHT
|
||||
} from '../consts/const'
|
||||
import {FaClipboardList} from 'react-icons/fa'
|
||||
import useTranslate from '../hooks/useTranslate'
|
||||
import {openUrl} from '@kky002/kky-util'
|
||||
import useKeyService from '../hooks/useKeyService'
|
||||
import Ask from './Ask'
|
||||
import {v4} from 'uuid'
|
||||
import RateExtension from '../components/RateExtension'
|
||||
|
||||
const Body = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const inputting = useAppSelector(state => state.env.inputting)
|
||||
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.tempData.compact)
|
||||
const floatKeyPointsSegIdx = useAppSelector(state => state.env.floatKeyPointsSegIdx)
|
||||
const translateEnable = useAppSelector(state => state.env.envData.translateEnable)
|
||||
const summarizeEnable = useAppSelector(state => state.env.envData.summarizeEnable)
|
||||
const {addSummarizeTask, addAskTask} = useTranslate()
|
||||
// const infos = useAppSelector(state => state.env.infos)
|
||||
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.tempData.curSummaryType)
|
||||
// const title = useAppSelector(state => state.env.title)
|
||||
// const fontSize = useAppSelector(state => state.env.envData.fontSize)
|
||||
const searchText = useAppSelector(state => state.env.searchText)
|
||||
const asks = useAppSelector(state => state.env.asks)
|
||||
// const recommendIdx = useMemo(() => random(0, 3), [])
|
||||
const showSearchInput = useMemo(() => {
|
||||
return (segments != null && segments.length > 0) && (envData.searchEnabled ? envData.searchEnabled : (envData.askEnabled ?? ASK_ENABLED_DEFAULT))
|
||||
}, [envData.askEnabled, envData.searchEnabled, segments])
|
||||
const searchPlaceholder = useMemo(() => {
|
||||
let placeholder = ''
|
||||
if (envData.searchEnabled) {
|
||||
if (envData.askEnabled??ASK_ENABLED_DEFAULT) {
|
||||
placeholder = '搜索或提问字幕内容(按Enter提问)'
|
||||
} else {
|
||||
placeholder = '搜索字幕内容'
|
||||
}
|
||||
} else {
|
||||
if (envData.askEnabled??ASK_ENABLED_DEFAULT) {
|
||||
placeholder = '提问字幕内容'
|
||||
}
|
||||
}
|
||||
return placeholder
|
||||
}, [envData.askEnabled, envData.searchEnabled])
|
||||
|
||||
const normalCallback = useCallback(() => {
|
||||
dispatch(setTempData({
|
||||
compact: false
|
||||
}))
|
||||
}, [dispatch])
|
||||
|
||||
const compactCallback = useCallback(() => {
|
||||
dispatch(setTempData({
|
||||
compact: true
|
||||
}))
|
||||
}, [dispatch])
|
||||
|
||||
const posCallback = useCallback(() => {
|
||||
dispatch(setNeedScroll(true))
|
||||
}, [dispatch])
|
||||
|
||||
const onSummarizeAll = useCallback(() => {
|
||||
const apiKey = envData.aiType === 'gemini'?envData.geminiApiKey:envData.apiKey
|
||||
if (!apiKey) {
|
||||
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(curSummaryType, segment).catch(console.error)
|
||||
}
|
||||
toast.success(`已添加${segments_.length}个总结任务!`)
|
||||
}
|
||||
}, [addSummarizeTask, curSummaryType, dispatch, envData.aiType, envData.apiKey, envData.geminiApiKey, segments])
|
||||
|
||||
const onFoldAll = useCallback(() => {
|
||||
dispatch(setFoldAll(!foldAll))
|
||||
for (const ask of asks) {
|
||||
dispatch(mergeAskInfo({
|
||||
id: ask.id,
|
||||
fold: !foldAll
|
||||
}))
|
||||
}
|
||||
for (const segment of segments ?? []) {
|
||||
dispatch(setSegmentFold({
|
||||
segmentStartIdx: segment.startIdx,
|
||||
fold: !foldAll
|
||||
}))
|
||||
}
|
||||
}, [asks, dispatch, foldAll, segments])
|
||||
|
||||
const toggleAutoTranslateCallback = useCallback(() => {
|
||||
const apiKey = envData.aiType === 'gemini'?envData.geminiApiKey:envData.apiKey
|
||||
if (apiKey) {
|
||||
dispatch(setAutoTranslate(!autoTranslate))
|
||||
} else {
|
||||
toast.error('请先在选项页面设置ApiKey!')
|
||||
}
|
||||
}, [autoTranslate, dispatch, envData.aiType, envData.apiKey, envData.geminiApiKey])
|
||||
|
||||
const onEnableAutoScroll = useCallback(() => {
|
||||
dispatch(setAutoScroll(true))
|
||||
dispatch(setNeedScroll(true))
|
||||
}, [dispatch])
|
||||
|
||||
const onWheel = useCallback(() => {
|
||||
if (autoScroll) {
|
||||
dispatch(setAutoScroll(false))
|
||||
}
|
||||
}, [autoScroll, dispatch])
|
||||
|
||||
// const onCopy = useCallback(() => {
|
||||
// const [success, content] = getSummarize(title, segments, curSummaryType)
|
||||
// if (success) {
|
||||
// navigator.clipboard.writeText(content).then(() => {
|
||||
// toast.success('复制成功')
|
||||
// }).catch(console.error)
|
||||
// }
|
||||
// }, [curSummaryType, segments, title])
|
||||
|
||||
const onSearchTextChange = useCallback((e: any) => {
|
||||
const searchText = e.target.value
|
||||
dispatch(setSearchText(searchText))
|
||||
}, [dispatch])
|
||||
|
||||
const onClearSearchText = useCallback(() => {
|
||||
dispatch(setSearchText(''))
|
||||
}, [dispatch])
|
||||
|
||||
const onAsk = useCallback(() => {
|
||||
if ((envData.askEnabled??ASK_ENABLED_DEFAULT) && searchText) {
|
||||
const apiKey = envData.aiType === 'gemini'?envData.geminiApiKey:envData.apiKey
|
||||
if (apiKey) {
|
||||
if (segments != null && segments.length > 0) {
|
||||
const id = v4()
|
||||
addAskTask(id, segments[0], searchText).catch(console.error)
|
||||
// 添加ask
|
||||
dispatch(addAskInfo({
|
||||
id,
|
||||
question: searchText,
|
||||
status: 'pending',
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
toast.error('请先在选项页面设置ApiKey!')
|
||||
}
|
||||
}
|
||||
}, [addAskTask, dispatch, envData.aiType, envData.apiKey, envData.askEnabled, envData.geminiApiKey, searchText, segments])
|
||||
|
||||
// service
|
||||
useKeyService()
|
||||
|
||||
// 自动滚动
|
||||
useEffect(() => {
|
||||
if (checkAutoScroll && curOffsetTop && autoScroll && !needScroll) {
|
||||
if (bodyRef.current.scrollTop <= curOffsetTop - bodyRef.current.offsetTop - (totalHeight-160) + (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'>
|
||||
{/* title */}
|
||||
<div className='absolute top-1 left-6 flex-center gap-1'>
|
||||
<AiOutlineAim className='cursor-pointer' onClick={posCallback} title='滚动到视频位置'/>
|
||||
{segments != null && segments.length > 0 &&
|
||||
<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>
|
||||
|
||||
{/* search */}
|
||||
{showSearchInput && <div className='px-2 py-1 flex flex-col relative'>
|
||||
<input type='text' className='input input-xs bg-base-200' placeholder={searchPlaceholder} value={searchText} onChange={onSearchTextChange} onKeyDown={e => {
|
||||
// enter
|
||||
if (e.key === 'Enter') {
|
||||
if (!inputting) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onAsk()
|
||||
dispatch(setSearchText(''))
|
||||
}
|
||||
}
|
||||
}}/>
|
||||
{searchText && <button className='absolute top-1 right-2 btn btn-ghost btn-xs btn-circle text-base-content/75' onClick={onClearSearchText}><AiOutlineCloseCircle/></button>}
|
||||
</div>}
|
||||
|
||||
{/* auto scroll btn */}
|
||||
{!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>}
|
||||
|
||||
{/* body */}
|
||||
<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 - (showSearchInput ? SEARCH_BAR_HEIGHT : 0)}px`
|
||||
}}
|
||||
>
|
||||
{/* asks */}
|
||||
{asks.map(ask => <Ask key={ask.id} ask={ask}/>)}
|
||||
|
||||
{/* segments */}
|
||||
{segments?.map((segment, segmentIdx) => <SegmentCard key={segment.startIdx} segment={segment}
|
||||
segmentIdx={segmentIdx} bodyRef={bodyRef}/>)}
|
||||
|
||||
{/* tip */}
|
||||
<div className='text-sm font-semibold text-center'>快捷键提示</div>
|
||||
<ul className='list-disc text-sm desc pl-5'>
|
||||
<li>单击字幕跳转,双击字幕跳转+切换暂停。</li>
|
||||
<li>alt+单击字幕复制单条字幕。</li>
|
||||
<li>上下方向键来移动当前字幕(可先点击字幕使焦点在字幕列表内)。</li>
|
||||
</ul>
|
||||
|
||||
{/* <div className='flex flex-col items-center text-center pt-1 pb-2'> */}
|
||||
{/* <div className='font-semibold text-accent'>💡<span className='underline underline-offset-4'>提示</span>💡</div> */}
|
||||
{/* <div className='text-sm desc px-2'>可以尝试将<span className='text-amber-600 font-semibold'>概览</span>生成的内容粘贴到<span */}
|
||||
{/* className='text-secondary/75 font-semibold'>视频评论</span>里,发布后看看有什么效果🥳 */}
|
||||
{/* </div> */}
|
||||
{/* {(segments?.length ?? 0) > 0 && <button className='mt-1.5 btn btn-xs btn-info' */}
|
||||
{/* onClick={onCopy}>点击复制生成的{SUMMARIZE_TYPES[curSummaryType].name}<RiFileCopy2Line/> */}
|
||||
{/* </button>} */}
|
||||
{/* </div> */}
|
||||
<div className='flex flex-col'>
|
||||
{/* <div className='flex flex-col items-center text-center py-2 mx-4 border-t border-t-base-300'> */}
|
||||
{/* <div className='font-semibold text-accent flex items-center gap-1'><img src='/bibigpt.png' */}
|
||||
{/* alt='BibiGPT logo' */}
|
||||
{/* className='w-8 h-8'/>BibiGPT */}
|
||||
{/* </div> */}
|
||||
{/* <div className='text-sm px-2 desc'>这是<span className='text-amber-600 font-semibold text-base'>网页</span>版的字幕列表,支持<span */}
|
||||
{/* className='font-semibold'>任意</span>视频提取字幕总结(包括没有字幕的视频) */}
|
||||
{/* </div> */}
|
||||
{/* <div className='flex gap-2'> */}
|
||||
{/* <a title='BibiGPT' href='https://bibigpt.co/r/bilibili' */}
|
||||
{/* onClick={(e) => { */}
|
||||
{/* e.preventDefault() */}
|
||||
{/* openUrl('https://bibigpt.co/r/bilibili') */}
|
||||
{/* }} className='link text-sm text-accent'>✨ BibiGPT ✨</a> */}
|
||||
{/* </div> */}
|
||||
{/* </div> */}
|
||||
<div className='flex flex-col items-center text-center py-2 mx-4 border-t border-t-base-300'>
|
||||
<div className='font-semibold text-accent flex items-center gap-1'><img src='/youtube-caption.png'
|
||||
alt='youtube caption logo'
|
||||
className='w-8 h-8'/>YouTube Caption
|
||||
</div>
|
||||
<div className='text-sm px-2 desc'>这是<span className='text-amber-600 font-semibold text-base'>YouTube</span>版的字幕列表
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
<a title='Chrome商店' href='https://chromewebstore.google.com/detail/fiaeclpicddpifeflpmlgmbjgaedladf'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
openUrl('https://chromewebstore.google.com/detail/fiaeclpicddpifeflpmlgmbjgaedladf')
|
||||
}} className='link text-sm text-accent'>Chrome商店</a>
|
||||
<a title='Edge商店'
|
||||
href='https://microsoftedge.microsoft.com/addons/detail/galeejdehabppfgooagmkclpppnbccpc'
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
openUrl('https://microsoftedge.microsoft.com/addons/detail/galeejdehabppfgooagmkclpppnbccpc')
|
||||
}} className='link text-sm text-accent'>Edge商店</a>
|
||||
<a title='Crx搜搜(国内可访问)'
|
||||
href='https://www.crxsoso.com/webstore/detail/fiaeclpicddpifeflpmlgmbjgaedladf'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
openUrl('https://www.crxsoso.com/webstore/detail/fiaeclpicddpifeflpmlgmbjgaedladf')
|
||||
}} className='link text-sm text-accent'>Crx搜搜(国内可访问)</a>
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className='flex flex-col items-center text-center py-2 mx-4 border-t border-t-base-300'> */}
|
||||
{/* <div className='font-semibold text-accent flex items-center gap-1'><img src='/my-article-summarizer.png' */}
|
||||
{/* alt='My Article Summarizer logo' */}
|
||||
{/* className='w-8 h-8'/>My Article Summarizer */}
|
||||
{/* </div> */}
|
||||
{/* <div className='text-sm px-2 desc'>网页文章总结(有每日免费额度,无需apikey)。</div> */}
|
||||
{/* <div className='flex gap-2'> */}
|
||||
{/* <a title='Chrome商店' href='https://chromewebstore.google.com/detail/my-article-summarizer/nanlpakfialleijdidafldapoifndngn' */}
|
||||
{/* onClick={(e) => { */}
|
||||
{/* e.preventDefault() */}
|
||||
{/* openUrl('https://chromewebstore.google.com/detail/my-article-summarizer/nanlpakfialleijdidafldapoifndngn') */}
|
||||
{/* }} className='link text-sm text-accent'>Chrome商店</a> */}
|
||||
{/* <a title='Crx搜搜(国内可访问)' */}
|
||||
{/* href='https://www.crxsoso.com/webstore/detail/nanlpakfialleijdidafldapoifndngn' */}
|
||||
{/* onClick={(e) => { */}
|
||||
{/* e.preventDefault() */}
|
||||
{/* openUrl('https://www.crxsoso.com/webstore/detail/nanlpakfialleijdidafldapoifndngn') */}
|
||||
{/* }} className='link text-sm text-accent'>Crx搜搜(国内可访问)</a> */}
|
||||
{/* </div> */}
|
||||
{/* </div> */}
|
||||
</div>
|
||||
<div className='p-2'><RateExtension/></div>
|
||||
</div>
|
||||
{/* recommend */}
|
||||
{/* <div className='p-0.5' style={{ */}
|
||||
{/* height: `${RECOMMEND_HEIGHT}px` */}
|
||||
{/* }}> */}
|
||||
{/* {recommendIdx === 0 && <div className='flex items-center gap-1.5 rounded shadow-sm bg-base-200/10'> */}
|
||||
{/* <a className='link link-accent link-hover font-semibold text-sm flex items-center' onClick={(e) => { */}
|
||||
{/* e.preventDefault() */}
|
||||
{/* openUrl('https://bibigpt.co/r/bilibili') */}
|
||||
{/* }}><img src='/bibigpt.png' */}
|
||||
{/* alt='BibiGPT logo' */}
|
||||
{/* className='w-8 h-8'/>✨ BibiGPT ✨</a> */}
|
||||
{/* <span className='text-sm desc'>支持任意视频的网页版总结。</span> */}
|
||||
{/* </div>} */}
|
||||
{/* {recommendIdx === 1 && <div className='flex items-center gap-1 rounded shadow-sm bg-base-200/10'> */}
|
||||
{/* <a className='link link-accent link-hover font-semibold text-sm flex items-center' onClick={(e) => { */}
|
||||
{/* e.preventDefault() */}
|
||||
{/* openUrl('https://chromewebstore.google.com/detail/fiaeclpicddpifeflpmlgmbjgaedladf') */}
|
||||
{/* }}><img src='/youtube-caption.png' */}
|
||||
{/* alt='youtube caption logo' */}
|
||||
{/* className='w-8 h-8'/>YouTube Caption</a> */}
|
||||
{/* <span className='text-sm desc'>YouTube版的字幕列表。</span> */}
|
||||
{/* </div>} */}
|
||||
{/* {recommendIdx === 2 && <div className='flex items-center gap-1 rounded shadow-sm bg-base-200/10'> */}
|
||||
{/* <a className='link link-accent link-hover font-semibold text-sm flex items-center' onClick={(e) => { */}
|
||||
{/* e.preventDefault() */}
|
||||
{/* openUrl('https://chromewebstore.google.com/detail/nanlpakfialleijdidafldapoifndngn') */}
|
||||
{/* }}><img src='/my-article-summarizer.png' */}
|
||||
{/* alt='My Article Summarizer logo' */}
|
||||
{/* className='w-8 h-8'/>My Article Summarizer</a> */}
|
||||
{/* <span className='text-sm desc'>网页文章总结。</span> */}
|
||||
{/* </div>} */}
|
||||
{/* {recommendIdx === 3 && <div className='flex items-center gap-1 rounded shadow-sm bg-base-200/10'> */}
|
||||
{/* <a className='link link-accent link-hover font-semibold text-sm flex items-center' onClick={(e) => { */}
|
||||
{/* e.preventDefault() */}
|
||||
{/* openUrl('https://api.openai-up.com/register?aff=varM') */}
|
||||
{/* }}><img src='/openai-up.ico' */}
|
||||
{/* alt='Openai Up logo' */}
|
||||
{/* className='w-8 h-8'/>Openai代理</a> */}
|
||||
{/* <span className='text-sm desc flex items-center'>目前价格不到官方的6折<FaGripfire */}
|
||||
{/* className='text-amber-600'/></span> */}
|
||||
{/* </div>} */}
|
||||
{/* </div> */}
|
||||
</div>
|
||||
}
|
||||
|
||||
export default Body
|
30
src/components/CompactSegmentItem.tsx
Normal file
30
src/components/CompactSegmentItem.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React, {useMemo} from 'react'
|
||||
import {useAppSelector} from '../hooks/redux'
|
||||
import {getDisplay, getTransText} from '../utils/biz_util'
|
||||
import classNames from 'classnames'
|
||||
|
||||
const CompactSegmentItem = (props: {
|
||||
item: TranscriptItem
|
||||
idx: number
|
||||
isIn: boolean
|
||||
last: boolean
|
||||
moveCallback: (event: any) => void
|
||||
move2Callback: (event: any) => void
|
||||
}) => {
|
||||
const {item, idx, last, isIn, moveCallback, move2Callback} = 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} onDoubleClick={move2Callback}>
|
||||
<text className={classNames('font-medium', isIn ? 'text-primary underline' : '')}>{display.main}</text>
|
||||
{display.sub && <text className='desc'>({display.sub})</text>}</span>
|
||||
<span className='text-base-content/75'>{!last && ','}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default CompactSegmentItem
|
99
src/components/Header.tsx
Normal file
99
src/components/Header.tsx
Normal 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 '../utils/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
|
305
src/components/MoreBtn.tsx
Normal file
305
src/components/MoreBtn.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
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 {setEnvData, setTempData} from '../redux/envReducer'
|
||||
import {EventBusContext} from '../Router'
|
||||
import {EVENT_EXPAND, MESSAGE_TO_INJECT_DOWNLOAD_AUDIO} from '../consts/const'
|
||||
import {formatSrtTime, formatTime, formatVttTime} from '../utils/util'
|
||||
import {downloadText, openUrl} from '@kky002/kky-util'
|
||||
import toast from 'react-hot-toast'
|
||||
import {getSummarize} from '../utils/biz_util'
|
||||
import useMessage from '../messaging/useMessage'
|
||||
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.tempData.downloadType)
|
||||
const [moreVisible, setMoreVisible] = useState(false)
|
||||
const eventBus = useContext(EventBusContext)
|
||||
const segments = useAppSelector(state => state.env.segments)
|
||||
const url = useAppSelector(state => state.env.url)
|
||||
const title = useAppSelector(state => state.env.title)
|
||||
const curSummaryType = useAppSelector(state => state.env.tempData.curSummaryType)
|
||||
|
||||
const {sendInject} = useMessage()
|
||||
|
||||
const downloadCallback = useCallback((download: boolean) => {
|
||||
if (data == null) {
|
||||
return
|
||||
}
|
||||
|
||||
let fileName = title
|
||||
let s, suffix
|
||||
if (!downloadType || downloadType === 'text') {
|
||||
s = `${title??'无标题'}\n${url??'无链接'}\n\n`
|
||||
for (const item of data.body) {
|
||||
s += item.content + '\n'
|
||||
}
|
||||
suffix = 'txt'
|
||||
} else if (downloadType === 'textWithTime') {
|
||||
s = `${title??'无标题'}\n${url??'无链接'}\n\n`
|
||||
for (const item of data.body) {
|
||||
s += formatTime(item.from) + ' ' + item.content + '\n'
|
||||
}
|
||||
suffix = 'txt'
|
||||
} else if (downloadType === 'article') {
|
||||
s = `${title??'无标题'}\n${url??'无链接'}\n\n`
|
||||
for (const item of data.body) {
|
||||
s += item.content + ', '
|
||||
}
|
||||
s = s.substring(0, s.length - 1) // remove last ','
|
||||
suffix = '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'
|
||||
suffix = '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'
|
||||
suffix = 'vtt'
|
||||
} else if (downloadType === 'json') {
|
||||
s = JSON.stringify(data)
|
||||
suffix = 'json'
|
||||
} else if (downloadType === 'summarize') {
|
||||
s = `${title??'无标题'}\n${url??'无链接'}\n\n`
|
||||
const [success, content] = getSummarize(title, segments, curSummaryType)
|
||||
if (!success) return
|
||||
s += content
|
||||
fileName += ' - 总结'
|
||||
suffix = 'txt'
|
||||
} else {
|
||||
return
|
||||
}
|
||||
if (download) {
|
||||
downloadText(s, fileName+'.'+suffix)
|
||||
} else {
|
||||
navigator.clipboard.writeText(s).then(() => {
|
||||
toast.success('复制成功')
|
||||
}).catch(console.error)
|
||||
}
|
||||
setMoreVisible(false)
|
||||
}, [curSummaryType, data, downloadType, segments, title, url])
|
||||
|
||||
const downloadAudioCallback = useCallback(() => {
|
||||
sendInject(MESSAGE_TO_INJECT_DOWNLOAD_AUDIO, {})
|
||||
}, [])
|
||||
|
||||
const selectCallback = useCallback((e: any) => {
|
||||
dispatch(setTempData({
|
||||
downloadType: 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) => { */}
|
||||
{/* e.preventDefault() */}
|
||||
{/* e.stopPropagation() */}
|
||||
{/* openUrl('https://bibigpt.co/r/bilibili') */}
|
||||
{/* }}> */}
|
||||
{/* <img alt='BibiGPT' src='/bibigpt.png' className='w-[20px] h-[20px] bg-white rounded-sm p-0.5'/> */}
|
||||
{/* BibiGPT */}
|
||||
{/* </a> */}
|
||||
{/* </li> */}
|
||||
{/* <li className='hover:bg-accent'> */}
|
||||
{/* <a className='flex items-center' onClick={(e) => { */}
|
||||
{/* e.preventDefault() */}
|
||||
{/* e.stopPropagation() */}
|
||||
{/* openUrl('https://chromewebstore.google.com/detail/fiaeclpicddpifeflpmlgmbjgaedladf') */}
|
||||
{/* }}> */}
|
||||
{/* <img alt='youtube subtitle' src='/youtube-caption.png' */}
|
||||
{/* className='w-[20px] h-[20px] bg-white rounded-sm p-0.5'/> */}
|
||||
{/* Youtube Caption */}
|
||||
{/* </a> */}
|
||||
{/* </li> */}
|
||||
<li className='hover:bg-accent'>
|
||||
<a className='flex items-center' onClick={(e) => {
|
||||
chrome.runtime.openOptionsPage()
|
||||
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
|
32
src/components/NormalSegmentItem.tsx
Normal file
32
src/components/NormalSegmentItem.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React, {useMemo} from 'react'
|
||||
import {formatTime} from '../utils/util'
|
||||
import {useAppSelector} from '../hooks/redux'
|
||||
import {getDisplay, getTransText} from '../utils/biz_util'
|
||||
import classNames from 'classnames'
|
||||
|
||||
const NormalSegmentItem = (props: {
|
||||
item: TranscriptItem
|
||||
idx: number
|
||||
isIn: boolean
|
||||
moveCallback: (event: any) => void
|
||||
move2Callback: (event: any) => void
|
||||
}) => {
|
||||
const {item, idx, isIn, moveCallback, move2Callback} = 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} onDoubleClick={move2Callback}>
|
||||
<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
|
@@ -4,7 +4,7 @@ import { IoMdClose } from 'react-icons/io';
|
||||
import { setTempData } from '../redux/envReducer';
|
||||
import { useAppDispatch, useAppSelector } from '../hooks/redux';
|
||||
import { openUrl } from '@kky002/kky-util';
|
||||
import { isEdgeBrowser } from '../util/util';
|
||||
import { isEdgeBrowser } from '../utils/util';
|
||||
|
||||
const RateExtension: React.FC = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
|
258
src/components/SegmentCard.tsx
Normal file
258
src/components/SegmentCard.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import React, {MutableRefObject, useCallback, useEffect, useMemo, useRef} from 'react'
|
||||
import {useAppDispatch, useAppSelector} from '../hooks/redux'
|
||||
import {setFloatKeyPointsSegIdx, setSegmentFold, setTempData} from '../redux/envReducer'
|
||||
import classNames from 'classnames'
|
||||
import {FaClipboardList} from 'react-icons/fa'
|
||||
import {PAGE_MAIN, PAGE_SETTINGS, SUMMARIZE_THRESHOLD, SUMMARIZE_TYPES} from '../consts/const'
|
||||
import useTranslate from '../hooks/useTranslate'
|
||||
import {BsDashSquare, BsPlusSquare, CgFileDocument, FaQuestion, GrOverview, RiFileCopy2Line} from 'react-icons/all'
|
||||
import toast from 'react-hot-toast'
|
||||
import {getLastTime, getSummaryStr, isSummaryEmpty, parseStrTimeToSeconds} from '../utils/biz_util'
|
||||
import {useInViewport} from 'ahooks'
|
||||
import SegmentItem from './SegmentItem'
|
||||
import {stopPopFunc} from '../utils/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, false)
|
||||
}
|
||||
}, [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 envData = useAppSelector(state => state.env.envData)
|
||||
const fontSize = useAppSelector(state => state.env.envData.fontSize)
|
||||
const curSummaryType = useAppSelector(state => state.env.tempData.curSummaryType)
|
||||
const {addSummarizeTask} = useTranslate()
|
||||
|
||||
const onGenerate = useCallback(() => {
|
||||
const apiKey = envData.aiType === 'gemini'?envData.geminiApiKey:envData.apiKey
|
||||
if (apiKey) {
|
||||
addSummarizeTask(curSummaryType, segment).catch(console.error)
|
||||
} else {
|
||||
toast.error('请先在选项页面设置ApiKey!')
|
||||
}
|
||||
}, [addSummarizeTask, curSummaryType, envData.aiType, envData.apiKey, envData.geminiApiKey, segment])
|
||||
|
||||
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>}
|
||||
{summary?.type === 'question' && (summary.content != null) &&
|
||||
<div className={classNames('max-w-[90%] flex flex-col gap-1', fontSize === 'large' ? 'text-sm' : 'text-xs')}>
|
||||
{summary.content.map((question: any, idx: number) => <div key={idx}>
|
||||
<h2 className={classNames('font-semibold underline', fontSize === 'large' ? 'text-sm' : 'text-xs')}>{question.q}</h2>
|
||||
<div className={classNames('font-normal', fontSize === 'large' ? 'text-sm' : 'text-xs')}>{question.a}</div>
|
||||
</div>)}
|
||||
</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 == null) || summary.status === 'init') && <div className='desc-lighter text-xs'>{SUMMARIZE_TYPES[curSummaryType].desc}</div>}
|
||||
{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 compact = useAppSelector(state => state.env.tempData.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.tempData.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 && showCurrent) { // 当前Card有控制权
|
||||
if (!inViewport && (summary != null) && !isSummaryEmpty(summary)) {
|
||||
dispatch(setFloatKeyPointsSegIdx(segment.startIdx))
|
||||
} else {
|
||||
dispatch(setFloatKeyPointsSegIdx())
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [dispatch, fold, inViewport, segment.startIdx, showCurrent, summarizeFloat, summary])
|
||||
|
||||
const onSelBrief = useCallback(() => {
|
||||
dispatch(setTempData({
|
||||
curSummaryType: 'brief'
|
||||
}))
|
||||
}, [dispatch])
|
||||
|
||||
const onSelOverview = useCallback(() => {
|
||||
dispatch(setTempData({
|
||||
curSummaryType: 'overview'
|
||||
}))
|
||||
}, [dispatch])
|
||||
|
||||
const onSelKeypoint = useCallback(() => {
|
||||
dispatch(setTempData({
|
||||
curSummaryType: 'keypoint'
|
||||
}))
|
||||
}, [dispatch])
|
||||
|
||||
const onSelQuestion = useCallback(() => {
|
||||
dispatch(setTempData({
|
||||
curSummaryType: 'question'
|
||||
}))
|
||||
}, [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 > 0 &&
|
||||
<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={classNames('tab tab-lifted tab-xs', curSummaryType === 'question' && 'tab-active')} onClick={onSelQuestion}><FaQuestion/>问题</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 > 0 && <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
|
88
src/components/SegmentItem.tsx
Normal file
88
src/components/SegmentItem.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React, {useCallback, useEffect, useMemo, 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: TranscriptItem
|
||||
idx: number
|
||||
isIn: boolean
|
||||
needScroll?: boolean
|
||||
last: boolean
|
||||
}) => {
|
||||
const {bodyRef, item, idx, isIn, needScroll, last} = props
|
||||
const dispatch = useAppDispatch()
|
||||
const ref = useRef<any>()
|
||||
const {move} = useSubtitle()
|
||||
|
||||
const compact = useAppSelector(state => state.env.tempData.compact)
|
||||
const searchText = useAppSelector(state => state.env.searchText)
|
||||
const searchResult = useAppSelector(state => state.env.searchResult)
|
||||
const display = useMemo(() => {
|
||||
if (searchText) {
|
||||
return searchResult[item.idx+''] ? 'inline' : 'none'
|
||||
} else {
|
||||
return 'inline'
|
||||
}
|
||||
}, [item.idx, searchResult, searchText])
|
||||
|
||||
const moveCallback = useCallback((event: any) => {
|
||||
if (event.altKey) { // 复制
|
||||
navigator.clipboard.writeText(item.content).catch(console.error)
|
||||
} else {
|
||||
move(item.from, false)
|
||||
}
|
||||
}, [item.content, item.from, move])
|
||||
|
||||
const move2Callback = useCallback((event: any) => {
|
||||
if (event.altKey) { // 复制
|
||||
navigator.clipboard.writeText(item.content).catch(console.error)
|
||||
} else {
|
||||
move(item.from, true)
|
||||
}
|
||||
}, [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} style={{
|
||||
display
|
||||
}}>
|
||||
{compact
|
||||
? <CompactSegmentItem
|
||||
item={item}
|
||||
idx={idx}
|
||||
isIn={isIn}
|
||||
last={last}
|
||||
moveCallback={moveCallback}
|
||||
move2Callback={move2Callback}
|
||||
/>
|
||||
:
|
||||
<NormalSegmentItem
|
||||
item={item}
|
||||
idx={idx}
|
||||
isIn={isIn}
|
||||
moveCallback={moveCallback}
|
||||
move2Callback={move2Callback}
|
||||
/>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
|
||||
export default SegmentItem
|
Reference in New Issue
Block a user