You've already forked bilibili-subtitle
init
This commit is contained in:
168
src/biz/Body.tsx
Normal file
168
src/biz/Body.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, {useCallback, useEffect, useRef} from 'react'
|
||||
import {
|
||||
setAutoScroll,
|
||||
setAutoTranslate,
|
||||
setCheckAutoScroll,
|
||||
setCompact,
|
||||
setFoldAll,
|
||||
setNeedScroll,
|
||||
setPage,
|
||||
setSegmentFold
|
||||
} from '../redux/envReducer'
|
||||
import {useAppDispatch, useAppSelector} from '../hooks/redux'
|
||||
import {AiOutlineAim, FaRegArrowAltCircleDown, IoWarning, MdExpand, RiTranslate} from 'react-icons/all'
|
||||
import classNames from 'classnames'
|
||||
import toast from 'react-hot-toast'
|
||||
import SegmentCard from './SegmentCard'
|
||||
import {HEADER_HEIGHT, PAGE_SETTINGS, SUMMARIZE_ALL_THRESHOLD, TITLE_HEIGHT} from '../const'
|
||||
import {FaClipboardList} from 'react-icons/fa'
|
||||
import useTranslate from '../hooks/useTranslate'
|
||||
|
||||
const Body = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
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.compact)
|
||||
const apiKey = useAppSelector(state => state.env.envData.apiKey)
|
||||
const floatKeyPointsSegIdx = useAppSelector(state => state.env.floatKeyPointsSegIdx)
|
||||
const translateEnable = useAppSelector(state => state.env.envData.translateEnable)
|
||||
const summarizeEnable = useAppSelector(state => state.env.envData.summarizeEnable)
|
||||
const title = useAppSelector(state => state.env.title)
|
||||
const {addSummarizeTask} = useTranslate()
|
||||
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.curSummaryType)
|
||||
|
||||
const normalCallback = useCallback(() => {
|
||||
dispatch(setCompact(false))
|
||||
}, [dispatch])
|
||||
|
||||
const compactCallback = useCallback(() => {
|
||||
dispatch(setCompact(true))
|
||||
}, [dispatch])
|
||||
|
||||
const posCallback = useCallback(() => {
|
||||
dispatch(setNeedScroll(true))
|
||||
}, [dispatch])
|
||||
|
||||
const onSummarizeAll = useCallback(() => {
|
||||
if (!apiKey) {
|
||||
dispatch(setPage(PAGE_SETTINGS))
|
||||
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(title, curSummaryType, segment).catch(console.error)
|
||||
}
|
||||
toast.success(`已添加${segments_.length}个总结任务!`)
|
||||
}
|
||||
}, [addSummarizeTask, apiKey, curSummaryType, dispatch, segments, title])
|
||||
|
||||
const onFoldAll = useCallback(() => {
|
||||
dispatch(setFoldAll(!foldAll))
|
||||
for (const segment of segments ?? []) {
|
||||
dispatch(setSegmentFold({
|
||||
segmentStartIdx: segment.startIdx,
|
||||
fold: !foldAll
|
||||
}))
|
||||
}
|
||||
}, [dispatch, foldAll, segments])
|
||||
|
||||
const toggleAutoTranslateCallback = useCallback(() => {
|
||||
if (envData.apiKey) {
|
||||
dispatch(setAutoTranslate(!autoTranslate))
|
||||
} else {
|
||||
dispatch(setPage(PAGE_SETTINGS))
|
||||
toast.error('需要先设置ApiKey!')
|
||||
}
|
||||
}, [autoTranslate, dispatch, envData.apiKey])
|
||||
|
||||
const onEnableAutoScroll = useCallback(() => {
|
||||
dispatch(setAutoScroll(true))
|
||||
dispatch(setNeedScroll(true))
|
||||
}, [dispatch])
|
||||
|
||||
const onWheel = useCallback(() => {
|
||||
if (autoScroll) {
|
||||
dispatch(setAutoScroll(false))
|
||||
}
|
||||
}, [autoScroll, dispatch])
|
||||
|
||||
// 自动滚动
|
||||
useEffect(() => {
|
||||
if (checkAutoScroll && curOffsetTop && autoScroll && !needScroll) {
|
||||
if (bodyRef.current.scrollTop <= curOffsetTop - bodyRef.current.offsetTop - (totalHeight-120) + (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'>
|
||||
<div className='absolute top-1 left-6 flex-center gap-1'>
|
||||
<AiOutlineAim className='cursor-pointer' onClick={posCallback} title='滚动到视频位置'/>
|
||||
{segments != null && segments.length > 1 &&
|
||||
<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>
|
||||
{!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>}
|
||||
<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}px`
|
||||
}}
|
||||
>
|
||||
{segments?.map((segment, segmentIdx) => <SegmentCard key={segment.startIdx} segment={segment} segmentIdx={segmentIdx} bodyRef={bodyRef}/>)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default Body
|
33
src/biz/CompactSegmentItem.tsx
Normal file
33
src/biz/CompactSegmentItem.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import React, {useMemo} from 'react'
|
||||
import {useAppSelector} from '../hooks/redux'
|
||||
import {getDisplay, getTransText} from '../util/biz_util'
|
||||
import classNames from 'classnames'
|
||||
|
||||
const CompactSegmentItem = (props: {
|
||||
item: {
|
||||
from: number
|
||||
to: number
|
||||
content: string
|
||||
}
|
||||
idx: number
|
||||
isIn: boolean
|
||||
last: boolean
|
||||
moveCallback: (event: any) => void
|
||||
}) => {
|
||||
const {item, idx, last, isIn, moveCallback} = 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}>
|
||||
<text className={classNames('font-medium', isIn ? 'text-primary underline' : '')}>{display.main}</text>
|
||||
{display.sub && <text className='desc'>({display.sub})</text>}</span>
|
||||
<span>{!last && ', '}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default CompactSegmentItem
|
99
src/biz/Header.tsx
Normal file
99
src/biz/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 '../util/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
|
278
src/biz/MoreBtn.tsx
Normal file
278
src/biz/MoreBtn.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
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 {setDownloadType, setEnvData, setPage} from '../redux/envReducer'
|
||||
import {EventBusContext} from '../Router'
|
||||
import {EVENT_EXPAND, PAGE_SETTINGS} from '../const'
|
||||
import {formatSrtTime, formatTime, formatVttTime} from '../util/util'
|
||||
import {downloadText, openUrl} from '@kky002/kky-util'
|
||||
import toast from 'react-hot-toast'
|
||||
import {getSummarize} from '../util/biz_util'
|
||||
|
||||
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.downloadType)
|
||||
const [moreVisible, setMoreVisible] = useState(false)
|
||||
const eventBus = useContext(EventBusContext)
|
||||
const segments = useAppSelector(state => state.env.segments)
|
||||
const title = useAppSelector(state => state.env.title)
|
||||
const curSummaryType = useAppSelector(state => state.env.curSummaryType)
|
||||
|
||||
const downloadCallback = useCallback((download: boolean) => {
|
||||
if (data == null) {
|
||||
return
|
||||
}
|
||||
|
||||
let s, fileName
|
||||
if (!downloadType || downloadType === 'text') {
|
||||
s = ''
|
||||
for (const item of data.body) {
|
||||
s += item.content + '\n'
|
||||
}
|
||||
fileName = 'download.txt'
|
||||
} else if (downloadType === 'textWithTime') {
|
||||
s = ''
|
||||
for (const item of data.body) {
|
||||
s += formatTime(item.from) + ' ' + item.content + '\n'
|
||||
}
|
||||
fileName = 'download.txt'
|
||||
} else if (downloadType === 'article') {
|
||||
s = ''
|
||||
for (const item of data.body) {
|
||||
s += item.content + ', '
|
||||
}
|
||||
s = s.substring(0, s.length - 1) // remove last ','
|
||||
fileName = 'download.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'
|
||||
fileName = 'download.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'
|
||||
fileName = 'download.vtt'
|
||||
} else if (downloadType === 'json') {
|
||||
s = JSON.stringify(data)
|
||||
fileName = 'download.json'
|
||||
} else if (downloadType === 'summarize') {
|
||||
const [success, content] = getSummarize(title, segments, curSummaryType)
|
||||
if (!success) return
|
||||
s = content
|
||||
fileName = '总结.txt'
|
||||
} else {
|
||||
return
|
||||
}
|
||||
if (download) {
|
||||
downloadText(s, fileName)
|
||||
} else {
|
||||
navigator.clipboard.writeText(s).then(() => {
|
||||
toast.success('复制成功')
|
||||
}).catch(console.error)
|
||||
}
|
||||
setMoreVisible(false)
|
||||
}, [curSummaryType, data, downloadType, segments, title])
|
||||
|
||||
const downloadAudioCallback = useCallback(() => {
|
||||
window.parent.postMessage({
|
||||
type: 'downloadAudio',
|
||||
}, '*')
|
||||
}, [])
|
||||
|
||||
const selectCallback = useCallback((e: any) => {
|
||||
dispatch(setDownloadType(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) => {
|
||||
dispatch(setPage(PAGE_SETTINGS))
|
||||
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
|
35
src/biz/NormalSegmentItem.tsx
Normal file
35
src/biz/NormalSegmentItem.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React, {useMemo} from 'react'
|
||||
import {formatTime} from '../util/util'
|
||||
import {useAppSelector} from '../hooks/redux'
|
||||
import {getDisplay, getTransText} from '../util/biz_util'
|
||||
import classNames from 'classnames'
|
||||
|
||||
const NormalSegmentItem = (props: {
|
||||
item: {
|
||||
from: number
|
||||
to: number
|
||||
content: string
|
||||
}
|
||||
idx: number
|
||||
isIn: boolean
|
||||
moveCallback: (event: any) => void
|
||||
}) => {
|
||||
const {item, idx, isIn, moveCallback} = 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}>
|
||||
<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
|
239
src/biz/SegmentCard.tsx
Normal file
239
src/biz/SegmentCard.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import React, {MutableRefObject, useCallback, useEffect, useMemo, useRef} from 'react'
|
||||
import {useAppDispatch, useAppSelector} from '../hooks/redux'
|
||||
import {setCurSummaryType, setFloatKeyPointsSegIdx, setPage, setSegmentFold} from '../redux/envReducer'
|
||||
import classNames from 'classnames'
|
||||
import {FaClipboardList} from 'react-icons/fa'
|
||||
import {PAGE_MAIN, PAGE_SETTINGS, SUMMARIZE_THRESHOLD} from '../const'
|
||||
import useTranslate from '../hooks/useTranslate'
|
||||
import {BsDashSquare, BsPlusSquare, CgFileDocument, 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)
|
||||
}
|
||||
}, [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 apiKey = useAppSelector(state => state.env.envData.apiKey)
|
||||
const fontSize = useAppSelector(state => state.env.envData.fontSize)
|
||||
const title = useAppSelector(state => state.env.title)
|
||||
const curSummaryType = useAppSelector(state => state.env.curSummaryType)
|
||||
const {addSummarizeTask} = useTranslate()
|
||||
|
||||
const onGenerate = useCallback(() => {
|
||||
if (apiKey) {
|
||||
addSummarizeTask(title, curSummaryType, segment).catch(console.error)
|
||||
} else {
|
||||
dispatch(setPage(PAGE_SETTINGS))
|
||||
toast.error('需要先设置ApiKey!')
|
||||
}
|
||||
}, [addSummarizeTask, apiKey, curSummaryType, dispatch, segment, title])
|
||||
|
||||
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>}
|
||||
</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?.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.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.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(setCurSummaryType('brief'))
|
||||
}, [dispatch])
|
||||
|
||||
const onSelOverview = useCallback(() => {
|
||||
dispatch(setCurSummaryType('overview'))
|
||||
}, [dispatch])
|
||||
|
||||
const onSelKeypoint = useCallback(() => {
|
||||
dispatch(setCurSummaryType('keypoint'))
|
||||
}, [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 > 1 &&
|
||||
<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="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 > 1 && <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
|
70
src/biz/SegmentItem.tsx
Normal file
70
src/biz/SegmentItem.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import React, {useCallback, useEffect, 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: {
|
||||
from: number
|
||||
to: number
|
||||
content: string
|
||||
}
|
||||
idx: number
|
||||
isIn: boolean
|
||||
needScroll?: boolean
|
||||
last: boolean
|
||||
}) => {
|
||||
const dispatch = useAppDispatch()
|
||||
const {bodyRef, item, idx, isIn, needScroll, last} = props
|
||||
const ref = useRef<any>()
|
||||
const {move} = useSubtitle()
|
||||
const compact = useAppSelector(state => state.env.compact)
|
||||
|
||||
const moveCallback = useCallback((event: any) => {
|
||||
if (event.altKey) { // 复制
|
||||
navigator.clipboard.writeText(item.content).catch(console.error)
|
||||
} else {
|
||||
move(item.from)
|
||||
}
|
||||
}, [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}>
|
||||
{compact
|
||||
? <CompactSegmentItem
|
||||
item={item}
|
||||
idx={idx}
|
||||
isIn={isIn}
|
||||
last={last}
|
||||
moveCallback={moveCallback}
|
||||
/>
|
||||
:
|
||||
<NormalSegmentItem
|
||||
item={item}
|
||||
idx={idx}
|
||||
isIn={isIn}
|
||||
moveCallback={moveCallback}
|
||||
/>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
|
||||
export default SegmentItem
|
277
src/biz/Settings.tsx
Normal file
277
src/biz/Settings.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import React, {PropsWithChildren, useCallback, useMemo, useState} from 'react'
|
||||
import {setEnvData, setPage} from '../redux/envReducer'
|
||||
import {useAppDispatch, useAppSelector} from '../hooks/redux'
|
||||
import {
|
||||
HEADER_HEIGHT,
|
||||
LANGUAGE_DEFAULT,
|
||||
LANGUAGES,
|
||||
PAGE_MAIN,
|
||||
SERVER_URL_THIRD,
|
||||
SUMMARIZE_LANGUAGE_DEFAULT,
|
||||
TRANSLATE_FETCH_DEFAULT,
|
||||
TRANSLATE_FETCH_MAX,
|
||||
TRANSLATE_FETCH_MIN,
|
||||
TRANSLATE_FETCH_STEP,
|
||||
WORDS_DEFAULT,
|
||||
WORDS_MAX,
|
||||
WORDS_MIN,
|
||||
WORDS_STEP
|
||||
} from '../const'
|
||||
import {IoWarning} from 'react-icons/all'
|
||||
import classNames from 'classnames'
|
||||
import toast from 'react-hot-toast'
|
||||
import {useBoolean, useEventTarget} from 'ahooks'
|
||||
import {useEventChecked} from '@kky002/kky-hooks'
|
||||
|
||||
const Section = (props: {
|
||||
title: ShowElement
|
||||
htmlFor?: string
|
||||
} & PropsWithChildren) => {
|
||||
const {title, htmlFor, children} = props
|
||||
return <div className='flex flex-col gap-1'>
|
||||
<label className='font-medium desc-lighter text-xs' htmlFor={htmlFor}>{title}</label>
|
||||
<div className='flex flex-col gap-1 rounded py-2 px-2 bg-base-200/75'>{children}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
const FormItem = (props: {
|
||||
title: ShowElement
|
||||
tip?: string
|
||||
htmlFor?: string
|
||||
} & PropsWithChildren) => {
|
||||
const {title, tip, htmlFor, children} = props
|
||||
return <div className='flex items-center gap-2'>
|
||||
<div className={classNames('basis-3/12 flex-center', tip && 'tooltip tooltip-right z-[100] underline underline-offset-2 decoration-dashed')} data-tip={tip}>
|
||||
<label className='font-medium desc' htmlFor={htmlFor}>{title}</label>
|
||||
</div>
|
||||
<div className='basis-9/12 flex items-center'>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
const Settings = () => {
|
||||
const dispatch = useAppDispatch()
|
||||
const envData = useAppSelector(state => state.env.envData)
|
||||
const {value: autoExpandValue, onChange: setAutoExpandValue} = useEventChecked(envData.autoExpand)
|
||||
// const {value: autoScrollValue, onChange: setAutoScrollValue} = useEventChecked(envData.autoScroll)
|
||||
const {value: translateEnableValue, onChange: setTranslateEnableValue} = useEventChecked(envData.translateEnable)
|
||||
const {value: summarizeEnableValue, onChange: setSummarizeEnableValue} = useEventChecked(envData.summarizeEnable)
|
||||
const {value: summarizeFloatValue, onChange: setSummarizeFloatValue} = useEventChecked(envData.summarizeFloat)
|
||||
const [apiKeyValue, { onChange: onChangeApiKeyValue }] = useEventTarget({initialValue: envData.apiKey??''})
|
||||
const [serverUrlValue, setServerUrlValue] = useState(envData.serverUrl)
|
||||
const [languageValue, { onChange: onChangeLanguageValue }] = useEventTarget({initialValue: envData.language??LANGUAGE_DEFAULT})
|
||||
const [summarizeLanguageValue, { onChange: onChangeSummarizeLanguageValue }] = useEventTarget({initialValue: envData.summarizeLanguage??SUMMARIZE_LANGUAGE_DEFAULT})
|
||||
const [hideOnDisableAutoTranslateValue, setHideOnDisableAutoTranslateValue] = useState(envData.hideOnDisableAutoTranslate)
|
||||
const [themeValue, setThemeValue] = useState(envData.theme)
|
||||
const [fontSizeValue, setFontSizeValue] = useState(envData.fontSize)
|
||||
const [transDisplayValue, setTransDisplayValue] = useState(envData.transDisplay)
|
||||
const [wordsValue, setWordsValue] = useState(envData.words??WORDS_DEFAULT)
|
||||
const [fetchAmountValue, setFetchAmountValue] = useState(envData.fetchAmount??TRANSLATE_FETCH_DEFAULT)
|
||||
const [moreFold, {toggle: toggleMoreFold}] = useBoolean(true)
|
||||
const fold = useAppSelector(state => state.env.fold)
|
||||
const totalHeight = useAppSelector(state => state.env.totalHeight)
|
||||
const wordsList = useMemo(() => {
|
||||
const list = []
|
||||
for (let i = WORDS_MIN; i <= WORDS_MAX; i += WORDS_STEP) {
|
||||
list.push(i)
|
||||
}
|
||||
return list
|
||||
}, [])
|
||||
const transFetchAmountList = useMemo(() => {
|
||||
const list = []
|
||||
for (let i = TRANSLATE_FETCH_MIN; i <= TRANSLATE_FETCH_MAX; i += TRANSLATE_FETCH_STEP) {
|
||||
list.push(i)
|
||||
}
|
||||
return list
|
||||
}, [])
|
||||
|
||||
const onChangeHideOnDisableAutoTranslate = useCallback((e: any) => {
|
||||
setHideOnDisableAutoTranslateValue(e.target.checked)
|
||||
}, [])
|
||||
|
||||
const onSave = useCallback(() => {
|
||||
dispatch(setEnvData({
|
||||
autoExpand: autoExpandValue,
|
||||
apiKey: apiKeyValue,
|
||||
serverUrl: serverUrlValue,
|
||||
translateEnable: translateEnableValue,
|
||||
language: languageValue,
|
||||
hideOnDisableAutoTranslate: hideOnDisableAutoTranslateValue,
|
||||
theme: themeValue,
|
||||
transDisplay: transDisplayValue,
|
||||
summarizeEnable: summarizeEnableValue,
|
||||
summarizeFloat: summarizeFloatValue,
|
||||
summarizeLanguage: summarizeLanguageValue,
|
||||
words: wordsValue,
|
||||
fetchAmount: fetchAmountValue,
|
||||
fontSize: fontSizeValue,
|
||||
}))
|
||||
dispatch(setPage(PAGE_MAIN))
|
||||
toast.success('保存成功')
|
||||
}, [fontSizeValue, apiKeyValue, autoExpandValue, dispatch, fetchAmountValue, hideOnDisableAutoTranslateValue, languageValue, serverUrlValue, summarizeEnableValue, summarizeFloatValue, summarizeLanguageValue, themeValue, transDisplayValue, translateEnableValue, wordsValue])
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
dispatch(setPage(PAGE_MAIN))
|
||||
}, [dispatch])
|
||||
|
||||
const onFetchAmountChange = useCallback((e: any) => {
|
||||
setFetchAmountValue(parseInt(e.target.value))
|
||||
}, [])
|
||||
|
||||
const onWordsChange = useCallback((e: any) => {
|
||||
setWordsValue(parseInt(e.target.value))
|
||||
}, [])
|
||||
|
||||
const onSel1 = useCallback(() => {
|
||||
setTransDisplayValue('originPrimary')
|
||||
}, [])
|
||||
|
||||
const onSel2 = useCallback(() => {
|
||||
setTransDisplayValue('targetPrimary')
|
||||
}, [])
|
||||
|
||||
const onSel3 = useCallback(() => {
|
||||
setTransDisplayValue('target')
|
||||
}, [])
|
||||
|
||||
const onSelTheme1 = useCallback(() => {
|
||||
setThemeValue('system')
|
||||
}, [])
|
||||
|
||||
const onSelTheme2 = useCallback(() => {
|
||||
setThemeValue('light')
|
||||
}, [])
|
||||
|
||||
const onSelTheme3 = useCallback(() => {
|
||||
setThemeValue('dark')
|
||||
}, [])
|
||||
|
||||
const onSelFontSize1 = useCallback(() => {
|
||||
setFontSizeValue('normal')
|
||||
}, [])
|
||||
|
||||
const onSelFontSize2 = useCallback(() => {
|
||||
setFontSizeValue('large')
|
||||
}, [])
|
||||
|
||||
return <div className='text-sm overflow-y-auto' style={{
|
||||
height: fold?undefined:`${totalHeight-HEADER_HEIGHT}px`,
|
||||
}}>
|
||||
<div className="flex flex-col gap-3 p-2">
|
||||
<Section title='通用配置'>
|
||||
<FormItem title='自动展开' htmlFor='autoExpand' tip='是否视频有字幕时自动展开字幕列表'>
|
||||
<input id='autoExpand' type='checkbox' className='toggle toggle-primary' checked={autoExpandValue}
|
||||
onChange={setAutoExpandValue}/>
|
||||
</FormItem>
|
||||
<FormItem title='主题'>
|
||||
<div className="btn-group">
|
||||
<button onClick={onSelTheme1} className={classNames('btn btn-xs no-animation', (!themeValue || themeValue === 'system')?'btn-active':'')}>系统</button>
|
||||
<button onClick={onSelTheme2} className={classNames('btn btn-xs no-animation', themeValue === 'light'?'btn-active':'')}>浅色</button>
|
||||
<button onClick={onSelTheme3} className={classNames('btn btn-xs no-animation', themeValue === 'dark'?'btn-active':'')}>深色</button>
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem title='字体大小'>
|
||||
<div className="btn-group">
|
||||
<button onClick={onSelFontSize1} className={classNames('btn btn-xs no-animation', (!fontSizeValue || fontSizeValue === 'normal')?'btn-active':'')}>普通</button>
|
||||
<button onClick={onSelFontSize2} className={classNames('btn btn-xs no-animation', fontSizeValue === 'large'?'btn-active':'')}>加大</button>
|
||||
</div>
|
||||
</FormItem>
|
||||
</Section>
|
||||
<Section title='openai配置'>
|
||||
<FormItem title='ApiKey' htmlFor='apiKey'>
|
||||
<input id='apiKey' type='text' className='input input-sm input-bordered w-full' placeholder='sk-xxx' value={apiKeyValue} onChange={onChangeApiKeyValue}/>
|
||||
</FormItem>
|
||||
<FormItem title='服务器' htmlFor='serverUrl'>
|
||||
<input id='serverUrl' type='text' className='input input-sm input-bordered w-full' placeholder='服务器地址,默认使用官方地址' value={serverUrlValue} onChange={e => setServerUrlValue(e.target.value)}/>
|
||||
</FormItem>
|
||||
<div className='flex justify-center'>
|
||||
<a className='link text-xs' onClick={toggleMoreFold}>{moreFold?'点击查看说明':'点击折叠说明'}</a>
|
||||
</div>
|
||||
{!moreFold && <div>
|
||||
<ul className='pl-3 list-decimal desc text-xs'>
|
||||
<li>官方服务器需要科学上网才能访问</li>
|
||||
<li>官方网址:<a className='link' href='https://platform.openai.com/' target='_blank' rel="noreferrer">openai.com</a></li>
|
||||
<li>支持官方代理(使用官方ApiKey):<a className='link' onClick={() => setServerUrlValue(SERVER_URL_THIRD)} rel='noreferrer'>点击设置</a></li>
|
||||
<li>支持代理(配合ApiKey):<a className='link' href='https://api2d.com/' target='_blank' rel="noreferrer">api2d</a> | <a className='link' onClick={() => setServerUrlValue('https://openai.api2d.net')} rel='noreferrer'>点击设置</a></li>
|
||||
<li>支持代理(配合ApiKey):<a className='link' href='https://openaimax.com/' target='_blank' rel="noreferrer">OpenAI-Max</a> | <a className='link' onClick={() => setServerUrlValue('https://api.openaimax.com')} rel='noreferrer'>点击设置</a></li>
|
||||
<li>支持代理(配合ApiKey):<a className='link' href='https://openai-sb.com/' target='_blank' rel="noreferrer">OpenAI-SB</a> | <a className='link' onClick={() => setServerUrlValue('https://api.openai-sb.com')} rel='noreferrer'>点击设置</a></li>
|
||||
<li>支持代理(配合ApiKey):<a className='link' href='https://www.ohmygpt.com/' target='_blank' rel="noreferrer">OhMyGPT</a> | <a className='link' onClick={() => setServerUrlValue('https://api.ohmygpt.com')} rel='noreferrer'>点击设置</a></li>
|
||||
<li>支持代理(配合ApiKey):<a className='link' href='https://aiproxy.io/' target='_blank' rel="noreferrer">AIProxy</a> | <a className='link' onClick={() => setServerUrlValue('https://api.aiproxy.io')} rel='noreferrer'>点击设置</a></li>
|
||||
<li>支持代理(配合ApiKey):<a className='link' href='https://key-rental.bowen.cool/' target='_blank' rel="noreferrer">Key Rental</a> | <a className='link' onClick={() => setServerUrlValue('https://key-rental-api.bowen.cool/openai')} rel='noreferrer'>点击设置</a></li>
|
||||
<li>支持其他第三方代理,有问题可加群交流</li>
|
||||
</ul>
|
||||
</div>}
|
||||
</Section>
|
||||
<Section title={<div className='flex items-center'>
|
||||
翻译配置
|
||||
{!apiKeyValue && <div className='tooltip tooltip-right ml-1' data-tip='未设置ApiKey无法使用'>
|
||||
<IoWarning className='text-sm text-warning'/>
|
||||
</div>}
|
||||
</div>}>
|
||||
<FormItem title='启用翻译' htmlFor='translateEnable'>
|
||||
<input id='translateEnable' type='checkbox' className='toggle toggle-primary' checked={translateEnableValue}
|
||||
onChange={setTranslateEnableValue}/>
|
||||
</FormItem>
|
||||
<FormItem title='目标语言' htmlFor='language'>
|
||||
<select id='language' className="select select-sm select-bordered" value={languageValue} onChange={onChangeLanguageValue}>
|
||||
{LANGUAGES.map(language => <option key={language.code} value={language.code}>{language.name}</option>)}
|
||||
</select>
|
||||
</FormItem>
|
||||
<FormItem title='翻译条数' tip='每次翻译条数'>
|
||||
<div className='flex-1 flex flex-col'>
|
||||
<input type="range" min={TRANSLATE_FETCH_MIN} max={TRANSLATE_FETCH_MAX} step={TRANSLATE_FETCH_STEP} value={fetchAmountValue} className="range range-primary" onChange={onFetchAmountChange} />
|
||||
<div className="w-full flex justify-between text-xs px-2">
|
||||
{transFetchAmountList.map(amount => <span key={amount}>{amount}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem title='翻译显示'>
|
||||
<div className="btn-group">
|
||||
<button onClick={onSel1} className={classNames('btn btn-xs no-animation', (!transDisplayValue || transDisplayValue === 'originPrimary')?'btn-active':'')}>原文为主</button>
|
||||
<button onClick={onSel2} className={classNames('btn btn-xs no-animation', transDisplayValue === 'targetPrimary'?'btn-active':'')}>翻译为主</button>
|
||||
<button onClick={onSel3} className={classNames('btn btn-xs no-animation', transDisplayValue === 'target'?'btn-active':'')}>仅翻译</button>
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem title='隐藏翻译' tip='取消自动翻译时,隐藏已翻译内容' htmlFor='hideOnDisableAutoTranslate'>
|
||||
<input id='hideOnDisableAutoTranslate' type='checkbox' className='toggle toggle-primary' checked={hideOnDisableAutoTranslateValue}
|
||||
onChange={onChangeHideOnDisableAutoTranslate}/>
|
||||
</FormItem>
|
||||
</Section>
|
||||
<Section title={<div className='flex items-center'>
|
||||
总结配置
|
||||
{!apiKeyValue && <div className='tooltip tooltip-right ml-1' data-tip='未设置ApiKey无法使用'>
|
||||
<IoWarning className='text-sm text-warning'/>
|
||||
</div>}
|
||||
</div>}>
|
||||
<FormItem title='启用总结' htmlFor='summarizeEnable'>
|
||||
<input id='summarizeEnable' type='checkbox' className='toggle toggle-primary' checked={summarizeEnableValue}
|
||||
onChange={setSummarizeEnableValue}/>
|
||||
</FormItem>
|
||||
<FormItem title='浮动窗口' htmlFor='summarizeFloat' tip='当前总结离开视野时,是否显示浮动窗口'>
|
||||
<input id='summarizeFloat' type='checkbox' className='toggle toggle-primary' checked={summarizeFloatValue}
|
||||
onChange={setSummarizeFloatValue}/>
|
||||
</FormItem>
|
||||
<FormItem title='总结语言' htmlFor='summarizeLanguage'>
|
||||
<select id='summarizeLanguage' className="select select-sm select-bordered" value={summarizeLanguageValue} onChange={onChangeSummarizeLanguageValue}>
|
||||
{LANGUAGES.map(language => <option key={language.code} value={language.code}>{language.name}</option>)}
|
||||
</select>
|
||||
</FormItem>
|
||||
<FormItem title='分段字数'>
|
||||
<div className='flex-1 flex flex-col'>
|
||||
<input type="range" min={WORDS_MIN} max={WORDS_MAX} step={WORDS_STEP} value={wordsValue} className="range range-primary" onChange={onWordsChange} />
|
||||
<div className="w-full flex justify-between text-xs px-2">
|
||||
{wordsList.map(words => <span key={words}>{words}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
</FormItem>
|
||||
</Section>
|
||||
<div className='flex justify-center gap-5'>
|
||||
<button className='btn btn-primary btn-sm' onClick={onSave}>保存</button>
|
||||
<button className='btn btn-sm' onClick={onCancel}>取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default Settings
|
Reference in New Issue
Block a user