diff --git a/apps/readest-app/src/__tests__/utils/ttsTime.test.ts b/apps/readest-app/src/__tests__/utils/ttsTime.test.ts new file mode 100644 index 00000000..27bda072 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/ttsTime.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { estimateTTSTime } from '@/utils/ttsTime'; +import { BookProgress } from '@/types/book'; + +const createProgress = (values: { + sectionMin: number; + totalMin: number; + pageCurrent: number; + pageTotal: number; +}) => { + return { + timeinfo: { + section: values.sectionMin, + total: values.totalMin, + }, + pageinfo: { + current: values.pageCurrent, + total: values.pageTotal, + }, + } as BookProgress; +}; + +describe('estimateTTSTime', () => { + it('estimates chapter and book remaining time at 1x', () => { + const progress = createProgress({ + sectionMin: 10, + totalMin: 60, + pageCurrent: 49, + pageTotal: 100, + }); + + const result = estimateTTSTime(progress, 1, 1_700_000_000_000); + + expect(result.chapterRemainingSec).toBe(600); + expect(result.bookRemainingSec).toBe(3600); + expect(result.finishAtTimestamp).toBe(1_700_003_600_000); + }); + + it('scales estimates by playback rate', () => { + const progress = createProgress({ + sectionMin: 12, + totalMin: 90, + pageCurrent: 44, + pageTotal: 90, + }); + + const result = estimateTTSTime(progress, 1.5, 1000); + + expect(result.chapterRemainingSec).toBe(480); + expect(result.bookRemainingSec).toBe(3600); + expect(result.finishAtTimestamp).toBe(3_601_000); + }); + + it('falls back safely for missing progress', () => { + const result = estimateTTSTime(null, 2, 1000); + + expect(result.chapterRemainingSec).toBeNull(); + expect(result.bookRemainingSec).toBeNull(); + expect(result.finishAtTimestamp).toBeNull(); + }); + + it('uses book remaining to compute finish time', () => { + const progress = createProgress({ + sectionMin: 1, + totalMin: 2, + pageCurrent: 99, + pageTotal: 100, + }); + + const result = estimateTTSTime(progress, 1, 1000); + + expect(result.bookRemainingSec).toBe(120); + expect(result.finishAtTimestamp).toBe(121000); + }); +}); diff --git a/apps/readest-app/src/app/reader/hooks/useTTSControl.ts b/apps/readest-app/src/app/reader/hooks/useTTSControl.ts index b09fb4ce..b7126b95 100644 --- a/apps/readest-app/src/app/reader/hooks/useTTSControl.ts +++ b/apps/readest-app/src/app/reader/hooks/useTTSControl.ts @@ -16,6 +16,7 @@ import { throttle } from '@/utils/throttle'; import { isCfiInLocation } from '@/utils/cfi'; import { getLocale } from '@/utils/misc'; import { invokeUseBackgroundAudio } from '@/utils/bridge'; +import { estimateTTSTime } from '@/utils/ttsTime'; import { useTTSMediaSession } from './useTTSMediaSession'; interface UseTTSControlProps { @@ -224,6 +225,11 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp }; const viewSettings = getViewSettings(bookKey); + const ttsTime = useMemo(() => { + const rate = viewSettings?.ttsRate ?? 1; + return estimateTTSTime(progress, rate); + }, [progress, viewSettings?.ttsRate]); + const getTTSTargetLang = useCallback((): string | null => { const vs = getViewSettings(bookKey); const ttsReadAloudText = vs?.ttsReadAloudText; @@ -641,6 +647,9 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp showBackToCurrentTTSLocation, timeoutOption, timeoutTimestamp, + chapterRemainingSec: ttsTime.chapterRemainingSec, + bookRemainingSec: ttsTime.bookRemainingSec, + finishAtTimestamp: ttsTime.finishAtTimestamp, handleTogglePlay, handleBackward, handleForward, diff --git a/apps/readest-app/src/utils/ttsTime.ts b/apps/readest-app/src/utils/ttsTime.ts new file mode 100644 index 00000000..08b94c4a --- /dev/null +++ b/apps/readest-app/src/utils/ttsTime.ts @@ -0,0 +1,44 @@ +import { BookProgress } from '@/types/book'; + +const toSeconds = (minutes?: number) => { + if (typeof minutes !== 'number' || !Number.isFinite(minutes) || minutes < 0) return null; + return Math.round(minutes * 60); +}; + +const getSafeRate = (rate?: number) => { + if (typeof rate !== 'number' || !Number.isFinite(rate) || rate <= 0) return 1; + return rate; +}; + +const applyRate = (seconds: number | null, rate: number) => { + if (seconds === null) return null; + return Math.max(0, Math.round(seconds / rate)); +}; + +export type TTSTimeEstimate = { + chapterRemainingSec: number | null; + bookRemainingSec: number | null; + finishAtTimestamp: number | null; +}; + +export const estimateTTSTime = ( + progress: BookProgress | null, + rate?: number, + now = Date.now(), +): TTSTimeEstimate => { + const safeRate = getSafeRate(rate); + const chapterRemainingBaseSec = toSeconds(progress?.timeinfo?.section); + const bookRemainingBaseSec = toSeconds(progress?.timeinfo?.total); + + const chapterRemainingSec = applyRate(chapterRemainingBaseSec, safeRate); + const bookRemainingSec = applyRate(bookRemainingBaseSec, safeRate); + + const finishAtTimestamp = + bookRemainingSec !== null && bookRemainingSec > 0 ? now + bookRemainingSec * 1000 : null; + + return { + chapterRemainingSec, + bookRemainingSec, + finishAtTimestamp, + }; +};