This commit is contained in:
IndieKKY
2024-10-05 20:03:02 +08:00
parent b283695b02
commit d52231227e
33 changed files with 44 additions and 51 deletions

70
src/components/Ask.tsx Normal file
View 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
View 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

View 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
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 '../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
View 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

View 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

View File

@@ -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()

View 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

View 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