Files
bilibili-subtitle/src/biz/SegmentCard.tsx
IndieKKY 7e4733dfa8 优化
2024-05-27 11:22:32 +08:00

261 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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