You've already forked bilibili-subtitle
261 lines
12 KiB
TypeScript
261 lines
12 KiB
TypeScript
import React, {MutableRefObject, useCallback, useEffect, useMemo, useRef} from 'react'
|
||
import {useAppDispatch, useAppSelector} from '../hooks/redux'
|
||
import {setFloatKeyPointsSegIdx, setPage, 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 '../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 '../util/biz_util'
|
||
import {useInViewport} from 'ahooks'
|
||
import SegmentItem from './SegmentItem'
|
||
import {stopPopFunc} from '../util/util'
|
||
import useSubtitle from '../hooks/useSubtitle'
|
||
|
||
const SummarizeItemOverview = (props: {
|
||
segment: Segment
|
||
summary: OverviewSummary
|
||
segmentIdx: number
|
||
overviewItem: OverviewItem
|
||
idx: number
|
||
}) => {
|
||
const { segment, summary, segmentIdx, overviewItem, idx} = props
|
||
|
||
const {move} = useSubtitle()
|
||
const time = parseStrTimeToSeconds(overviewItem.time)
|
||
const currentTime = useAppSelector(state => state.env.currentTime)
|
||
const isIn = useMemo(() => {
|
||
if (currentTime != null) {
|
||
// check in current segment
|
||
if (segment.items?.length > 0) {
|
||
const startTime = segment.items[0].from
|
||
const lastTime = segment.items[segment.items.length - 1].to
|
||
if (currentTime >= startTime && currentTime < lastTime) {
|
||
// check in current overview item
|
||
const nextOverviewItem = summary.content?.[idx + 1]
|
||
const nextTime = (nextOverviewItem != null)?parseStrTimeToSeconds(nextOverviewItem.time):null
|
||
return currentTime >= time && (nextTime == null || currentTime < nextTime)
|
||
}
|
||
}
|
||
}
|
||
return false
|
||
}, [currentTime, idx, segment.items, summary.content, time])
|
||
|
||
const moveCallback = useCallback((event: any) => {
|
||
if (event.altKey) { // 复制
|
||
navigator.clipboard.writeText(overviewItem.key).catch(console.error)
|
||
} else {
|
||
move(time, 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 {
|
||
dispatch(setPage(PAGE_SETTINGS))
|
||
toast.error('需要先设置ApiKey!')
|
||
}
|
||
}, [addSummarizeTask, curSummaryType, dispatch, 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 page = useAppSelector(state => state.env.page)
|
||
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 && page === PAGE_MAIN && showCurrent) { // 当前Card有控制权
|
||
if (!inViewport && (summary != null) && !isSummaryEmpty(summary)) {
|
||
dispatch(setFloatKeyPointsSegIdx(segment.startIdx))
|
||
} else {
|
||
dispatch(setFloatKeyPointsSegIdx())
|
||
}
|
||
}
|
||
}
|
||
}, [dispatch, fold, inViewport, page, segment.startIdx, showCurrent, summarizeFloat, summary])
|
||
|
||
const onSelBrief = useCallback(() => {
|
||
dispatch(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
|