5 Commits

Author SHA1 Message Date
IndieKKY
1a475d1f13 chore: release 1.14.1 2025-06-24 13:37:41 +08:00
IndieKKY
4a1ea0dbbe 章节模式配置项 2025-06-24 13:37:19 +08:00
IndieKKY
af80b8a51c chore: release 1.14.0 2025-06-23 16:29:16 +08:00
IndieKKY
404ab904cc 章节显示 2025-06-23 16:28:59 +08:00
IndieKKY
d53884269a 获取章节列表 2025-06-23 16:04:00 +08:00
9 changed files with 141 additions and 23 deletions

View File

@@ -1,7 +1,7 @@
{ {
"private": true, "private": true,
"name": "bilibili-subtitle", "name": "bilibili-subtitle",
"version": "1.13.1", "version": "1.14.1",
"type": "module", "type": "module",
"description": "哔哩哔哩字幕列表", "description": "哔哩哔哩字幕列表",
"main": "index.js", "main": "index.js",

View File

@@ -1,6 +1,6 @@
import {MutableRefObject, useCallback, useEffect, useMemo, useRef} from 'react' import {MutableRefObject, useCallback, useEffect, useMemo, useRef} from 'react'
import {useAppDispatch, useAppSelector} from '../hooks/redux' import {useAppDispatch, useAppSelector} from '../hooks/redux'
import {setFloatKeyPointsSegIdx, setSegmentFold, setTempData} from '../redux/envReducer' import {setFloatKeyPointsSegIdx, setNeedScroll, setSegmentFold, setTempData} from '../redux/envReducer'
import classNames from 'classnames' import classNames from 'classnames'
import {FaClipboardList, FaComments} from 'react-icons/fa' import {FaClipboardList, FaComments} from 'react-icons/fa'
import {SUMMARIZE_THRESHOLD, SUMMARIZE_TYPES} from '../consts/const' import {SUMMARIZE_THRESHOLD, SUMMARIZE_TYPES} from '../consts/const'
@@ -159,6 +159,7 @@ const SegmentCard = (props: {
} }
return undefined return undefined
}, [curSummaryType, segment.summaries]) }, [curSummaryType, segment.summaries])
const {move} = useSubtitle()
const onFold = useCallback(() => { const onFold = useCallback(() => {
dispatch(setSegmentFold({ dispatch(setSegmentFold({
@@ -167,6 +168,21 @@ const SegmentCard = (props: {
})) }))
}, [dispatch, segment.fold, segment.startIdx]) }, [dispatch, segment.fold, segment.startIdx])
const onChapterClick = useCallback(() => {
if (segment.items && segment.items.length > 0) {
// 展开当前segment
if (segment.fold) {
dispatch(setSegmentFold({
segmentStartIdx: segment.startIdx,
fold: false
}))
}
const firstItem = segment.items[0]
move(firstItem.from, false)
}
}, [dispatch, move, segment.fold, segment.items, segment.startIdx])
// 检测设置floatKeyPointsSegIdx // 检测设置floatKeyPointsSegIdx
useEffect(() => { useEffect(() => {
if (summarizeFloat) { // 已启用 if (summarizeFloat) { // 已启用
@@ -212,6 +228,10 @@ const SegmentCard = (props: {
return <div return <div
className={classNames('border border-base-300 bg-base-200/25 rounded flex flex-col m-1.5 p-1.5 gap-1', showCurrent && 'shadow shadow-md')}> className={classNames('border border-base-300 bg-base-200/25 rounded flex flex-col m-1.5 p-1.5 gap-1', showCurrent && 'shadow shadow-md')}>
{/* 章节标题 */}
{segment.chapterTitle && <div className='text-center py-1 px-2 bg-primary/10 rounded text-sm font-semibold text-primary border-b border-primary/20 cursor-pointer hover:bg-primary/20 transition-colors' onClick={onChapterClick}>
{segment.chapterTitle}
</div>}
<div className='relative flex justify-center min-h-[20px]'> <div className='relative flex justify-center min-h-[20px]'>
{segments != null && segments.length > 0 && {segments != null && segments.length > 0 &&
<div className='absolute left-0 top-0 bottom-0 text-xs select-none flex-center desc'> <div className='absolute left-0 top-0 bottom-0 text-xs select-none flex-center desc'>

View File

@@ -1,4 +1,4 @@
import { setAuthor, setCtime, setCurFetched, setCurInfo, setData, setInfos, setTitle, setUrl } from '@/redux/envReducer' import { setAuthor, setChapters, setCtime, setCurFetched, setCurInfo, setData, setInfos, setTitle, setUrl } from '@/redux/envReducer'
import { useAppDispatch, useAppSelector } from './redux' import { useAppDispatch, useAppSelector } from './redux'
import { AllAPPMessages, AllExtensionMessages, AllInjectMessages } from '@/message-typings' import { AllAPPMessages, AllExtensionMessages, AllInjectMessages } from '@/message-typings'
import { useMessaging, useMessagingService } from '@kky002/kky-message' import { useMessaging, useMessagingService } from '@kky002/kky-message'
@@ -19,6 +19,7 @@ const useMessageService = () => {
dispatch(setData(undefined)) dispatch(setData(undefined))
}, },
SET_VIDEO_INFO: async (params, context: MethodContext) => { SET_VIDEO_INFO: async (params, context: MethodContext) => {
dispatch(setChapters(params.chapters))
dispatch(setInfos(params.infos)) dispatch(setInfos(params.infos))
dispatch(setUrl(params.url)) dispatch(setUrl(params.url))
dispatch(setTitle(params.title)) dispatch(setTitle(params.title))

View File

@@ -31,6 +31,7 @@ const useSubtitleService = () => {
const envReady = useAppSelector(state => state.env.envReady) const envReady = useAppSelector(state => state.env.envReady)
const envData = useAppSelector((state: RootState) => state.env.envData) const envData = useAppSelector((state: RootState) => state.env.envData)
const data = useAppSelector((state: RootState) => state.env.data) const data = useAppSelector((state: RootState) => state.env.data)
const chapters = useAppSelector((state: RootState) => state.env.chapters)
const currentTime = useAppSelector((state: RootState) => state.currentTime.currentTime) const currentTime = useAppSelector((state: RootState) => state.currentTime.currentTime)
const curIdx = useAppSelector((state: RootState) => state.env.curIdx) const curIdx = useAppSelector((state: RootState) => state.env.curIdx)
const eventBus = useContext(EventBusContext) const eventBus = useContext(EventBusContext)
@@ -158,24 +159,78 @@ const useSubtitleService = () => {
size = Math.max(size, WORDS_MIN) size = Math.max(size, WORDS_MIN)
segments = [] segments = []
let transcriptItems: TranscriptItem[] = []
let totalLength = 0 // 如果启用章节模式且有章节信息,按章节分割
for (let i = 0; i < items.length; i++) { if ((envData.chapterMode ?? true) && chapters && chapters.length > 0) {
const item = items[i] for (let chapterIdx = 0; chapterIdx < chapters.length; chapterIdx++) {
transcriptItems.push(item) const chapter = chapters[chapterIdx]
totalLength += item.content.length const nextChapter = chapters[chapterIdx + 1]
if (totalLength >= size || i === items.length-1) { // new segment or last
// add // 找到属于当前章节的字幕项
segments.push({ const chapterItems = items.filter(item => {
items: transcriptItems, const itemTime = item.from
startIdx: transcriptItems[0].idx, return itemTime >= chapter.from && (nextChapter ? itemTime < nextChapter.from : true)
endIdx: transcriptItems[transcriptItems.length - 1].idx,
text: getWholeText(transcriptItems.map(item => item.content)),
summaries: {},
}) })
// reset
transcriptItems = [] if (chapterItems.length === 0) continue
totalLength = 0
// 如果章节内容过长,需要进一步分割
const chapterText = getWholeText(chapterItems.map(item => item.content))
if (chapterText.length <= size) {
// 章节内容不长作为一个segment
segments.push({
items: chapterItems,
startIdx: chapterItems[0].idx,
endIdx: chapterItems[chapterItems.length - 1].idx,
text: chapterText,
chapterTitle: chapter.content,
summaries: {},
})
} else {
// 章节内容过长需要分割成多个segment
let transcriptItems: TranscriptItem[] = []
let totalLength = 0
for (let i = 0; i < chapterItems.length; i++) {
const item = chapterItems[i]
transcriptItems.push(item)
totalLength += item.content.length
if (totalLength >= size || i === chapterItems.length - 1) {
segments.push({
items: transcriptItems,
startIdx: transcriptItems[0].idx,
endIdx: transcriptItems[transcriptItems.length - 1].idx,
text: getWholeText(transcriptItems.map(item => item.content)),
chapterTitle: chapter.content,
summaries: {},
})
// reset
transcriptItems = []
totalLength = 0
}
}
}
}
} else {
// 没有章节信息,按原来的逻辑分割
let transcriptItems: TranscriptItem[] = []
let totalLength = 0
for (let i = 0; i < items.length; i++) {
const item = items[i]
transcriptItems.push(item)
totalLength += item.content.length
if (totalLength >= size || i === items.length-1) { // new segment or last
// add
segments.push({
items: transcriptItems,
startIdx: transcriptItems[0].idx,
endIdx: transcriptItems[transcriptItems.length - 1].idx,
text: getWholeText(transcriptItems.map(item => item.content)),
summaries: {},
})
// reset
transcriptItems = []
totalLength = 0
}
} }
} }
} else { // 都放一个分段 } else { // 都放一个分段
@@ -189,7 +244,7 @@ const useSubtitleService = () => {
} }
} }
dispatch(setSegments(segments)) dispatch(setSegments(segments))
}, [data?.body, dispatch, envData]) }, [data?.body, dispatch, envData, chapters])
// 每0.5秒更新当前视频时间 // 每0.5秒更新当前视频时间
useInterval(() => { useInterval(() => {

View File

@@ -161,6 +161,21 @@ const debug = (...args: any[]) => {
if (aidOrBvid) { if (aidOrBvid) {
// aid,pages // aid,pages
let cid: string | undefined let cid: string | undefined
/**
* [
{
"type": 2,
"from": 0,
"to": 152, //单位秒
"content": "发现美",
"imgUrl": "http://i0.hdslb.com/bfs/vchapter/29168372111_0.jpg",
"logoUrl": "",
"team_type": "",
"team_name": ""
}
]
*/
let chapters: any[] = []
let subtitles let subtitles
if (aidOrBvid.toLowerCase().startsWith('av')) { // avxxx if (aidOrBvid.toLowerCase().startsWith('av')) { // avxxx
aid = parseInt(aidOrBvid.slice(2)) aid = parseInt(aidOrBvid.slice(2))
@@ -170,6 +185,7 @@ const debug = (...args: any[]) => {
author = pages[0].owner?.name author = pages[0].owner?.name
title = pages[0].part title = pages[0].part
await fetch(`https://api.bilibili.com/x/player/wbi/v2?aid=${aid}&cid=${cid!}`, { credentials: 'include' }).then(async res => await res.json()).then(res => { await fetch(`https://api.bilibili.com/x/player/wbi/v2?aid=${aid}&cid=${cid!}`, { credentials: 'include' }).then(async res => await res.json()).then(res => {
chapters = res.data.view_points ?? []
subtitles = res.data.subtitle.subtitles subtitles = res.data.subtitle.subtitles
}) })
} else { // bvxxx } else { // bvxxx
@@ -182,10 +198,14 @@ const debug = (...args: any[]) => {
pages = res.data.pages pages = res.data.pages
}) })
await fetch(`https://api.bilibili.com/x/player/wbi/v2?aid=${aid!}&cid=${cid!}`, { credentials: 'include' }).then(async res => await res.json()).then(res => { await fetch(`https://api.bilibili.com/x/player/wbi/v2?aid=${aid!}&cid=${cid!}`, { credentials: 'include' }).then(async res => await res.json()).then(res => {
chapters = res.data.view_points ?? []
subtitles = res.data.subtitle.subtitles subtitles = res.data.subtitle.subtitles
}) })
} }
//筛选chapters里type为2的
chapters = chapters.filter(chapter => chapter.type === 2)
// pagesMap // pagesMap
pagesMap = {} pagesMap = {}
pages.forEach(page => { pages.forEach(page => {
@@ -202,6 +222,7 @@ const debug = (...args: any[]) => {
ctime, ctime,
author, author,
pages, pages,
chapters,
infos: subtitles, infos: subtitles,
}) })
} }

View File

@@ -80,7 +80,7 @@ interface AppSetInfosMessage extends AppMessage<{ infos: any }> {
method: 'SET_INFOS' method: 'SET_INFOS'
} }
interface AppSetVideoInfoMessage extends AppMessage<{ url: string, title: string, aid: number | null, ctime: number | null, author?: string, pages: any, infos: any }> { interface AppSetVideoInfoMessage extends AppMessage<{ url: string, title: string, aid: number | null, ctime: number | null, author?: string, pages: any, chapters: any, infos: any }> {
method: 'SET_VIDEO_INFO' method: 'SET_VIDEO_INFO'
} }

View File

@@ -77,6 +77,7 @@ const OptionsPage = () => {
const {value: askEnabledValue, onChange: setAskEnabledValue} = useEventChecked(envData.askEnabled??ASK_ENABLED_DEFAULT) const {value: askEnabledValue, onChange: setAskEnabledValue} = useEventChecked(envData.askEnabled??ASK_ENABLED_DEFAULT)
const {value: cnSearchEnabledValue, onChange: setCnSearchEnabledValue} = useEventChecked(envData.cnSearchEnabled) const {value: cnSearchEnabledValue, onChange: setCnSearchEnabledValue} = useEventChecked(envData.cnSearchEnabled)
const {value: summarizeFloatValue, onChange: setSummarizeFloatValue} = useEventChecked(envData.summarizeFloat) const {value: summarizeFloatValue, onChange: setSummarizeFloatValue} = useEventChecked(envData.summarizeFloat)
const {value: chapterModeValue, onChange: setChapterModeValue} = useEventChecked(envData.chapterMode ?? true)
const [apiKeyValue, { onChange: onChangeApiKeyValue }] = useEventTarget({initialValue: envData.apiKey??''}) const [apiKeyValue, { onChange: onChangeApiKeyValue }] = useEventTarget({initialValue: envData.apiKey??''})
const [serverUrlValue, setServerUrlValue] = useState(envData.serverUrl) const [serverUrlValue, setServerUrlValue] = useState(envData.serverUrl)
const [languageValue, { onChange: onChangeLanguageValue }] = useEventTarget({initialValue: envData.language??LANGUAGE_DEFAULT}) const [languageValue, { onChange: onChangeLanguageValue }] = useEventTarget({initialValue: envData.language??LANGUAGE_DEFAULT})
@@ -139,6 +140,7 @@ const OptionsPage = () => {
searchEnabled: searchEnabledValue, searchEnabled: searchEnabledValue,
cnSearchEnabled: cnSearchEnabledValue, cnSearchEnabled: cnSearchEnabledValue,
askEnabled: askEnabledValue, askEnabled: askEnabledValue,
chapterMode: chapterModeValue,
})) }))
toast.success('保存成功') toast.success('保存成功')
sendExtension(null, 'CLOSE_SIDE_PANEL') sendExtension(null, 'CLOSE_SIDE_PANEL')
@@ -146,7 +148,7 @@ const OptionsPage = () => {
setTimeout(() => { setTimeout(() => {
window.close() window.close()
}, 3000) }, 3000)
}, [dispatch, sendExtension, sidePanelValue, autoInsertValue, autoExpandValue, apiKeyValue, serverUrlValue, modelValue, customModelValue, customModelTokensValue, translateEnableValue, languageValue, hideOnDisableAutoTranslateValue, themeValue, transDisplayValue, summarizeEnableValue, summarizeFloatValue, summarizeLanguageValue, wordsValue, fetchAmountValue, fontSizeValue, promptsValue, searchEnabledValue, cnSearchEnabledValue, askEnabledValue]) }, [dispatch, sendExtension, sidePanelValue, autoInsertValue, autoExpandValue, apiKeyValue, serverUrlValue, modelValue, customModelValue, customModelTokensValue, translateEnableValue, languageValue, hideOnDisableAutoTranslateValue, themeValue, transDisplayValue, summarizeEnableValue, summarizeFloatValue, summarizeLanguageValue, wordsValue, fetchAmountValue, fontSizeValue, promptsValue, searchEnabledValue, cnSearchEnabledValue, askEnabledValue, chapterModeValue])
const onCancel = useCallback(() => { const onCancel = useCallback(() => {
window.close() window.close()
@@ -207,6 +209,10 @@ const OptionsPage = () => {
<input id='autoExpand' type='checkbox' className='toggle toggle-primary' checked={autoExpandValue} <input id='autoExpand' type='checkbox' className='toggle toggle-primary' checked={autoExpandValue}
onChange={setAutoExpandValue}/> onChange={setAutoExpandValue}/>
</FormItem>} </FormItem>}
<FormItem title='章节模式' htmlFor='chapterMode' tip='如果视频包含章节,则会按章节分割(会导致总结只能按章节来)'>
<input id='chapterMode' type='checkbox' className='toggle toggle-primary' checked={chapterModeValue}
onChange={setChapterModeValue}/>
</FormItem>
<FormItem title='主题'> <FormItem title='主题'>
<div className="btn-group"> <div className="btn-group">
<button onClick={onSelTheme1} className={classNames('btn btn-sm no-animation', (!themeValue || themeValue === 'system')?'btn-active':'')}></button> <button onClick={onSelTheme1} className={classNames('btn btn-sm no-animation', (!themeValue || themeValue === 'system')?'btn-active':'')}></button>

View File

@@ -23,6 +23,7 @@ interface EnvState {
totalHeight: number totalHeight: number
curIdx?: number // 从0开始 curIdx?: number // 从0开始
needScroll?: boolean needScroll?: boolean
chapters?: Chapter[]
infos?: any[] infos?: any[]
curInfo?: any curInfo?: any
curFetched?: boolean curFetched?: boolean
@@ -274,6 +275,9 @@ export const slice = createSlice({
setAuthor: (state, action: PayloadAction<string | undefined>) => { setAuthor: (state, action: PayloadAction<string | undefined>) => {
state.author = action.payload state.author = action.payload
}, },
setChapters: (state, action: PayloadAction<Chapter[]>) => {
state.chapters = action.payload
},
setInfos: (state, action: PayloadAction<any[]>) => { setInfos: (state, action: PayloadAction<any[]>) => {
state.infos = action.payload state.infos = action.payload
}, },
@@ -346,6 +350,7 @@ export const {
mergeAskInfo, mergeAskInfo,
setCtime, setCtime,
setAuthor, setAuthor,
setChapters,
} = slice.actions } = slice.actions
export default slice.reducer export default slice.reducer

10
src/typings.d.ts vendored
View File

@@ -30,6 +30,9 @@ interface EnvData {
theme?: 'system' | 'light' | 'dark' theme?: 'system' | 'light' | 'dark'
fontSize?: 'normal' | 'large' fontSize?: 'normal' | 'large'
// chapter
chapterMode?: boolean // 是否启用章节模式undefined/null/true表示启用false表示禁用
// search // search
searchEnabled?: boolean searchEnabled?: boolean
cnSearchEnabled?: boolean cnSearchEnabled?: boolean
@@ -88,12 +91,19 @@ interface TranscriptItem {
idx: number idx: number
} }
interface Chapter {
from: number
to: number
content: string // 标题
}
interface Segment { interface Segment {
items: TranscriptItem[] items: TranscriptItem[]
startIdx: number // 从1开始 startIdx: number // 从1开始
endIdx: number endIdx: number
text: string text: string
fold?: boolean fold?: boolean
chapterTitle?: string // 章节标题
summaries: { summaries: {
[type: string]: Summary [type: string]: Summary
} }