Files
readest/apps/readest-app/src/app/reader/components/tts/TTSPanel.tsx
T

305 lines
9.5 KiB
TypeScript

import clsx from 'clsx';
import React, { useState, ChangeEvent, useEffect } from 'react';
import { MdPlayCircle, MdPauseCircle, MdFastRewind, MdFastForward, MdAlarm } from 'react-icons/md';
import { RiVoiceAiFill } from 'react-icons/ri';
import { MdCheck } from 'react-icons/md';
import { TTSVoice } from '@/services/tts';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { TranslationFunc, useTranslation } from '@/hooks/useTranslation';
import { useSettingsStore } from '@/store/settingsStore';
import { useDefaultIconSize, useResponsiveSize } from '@/hooks/useResponsiveSize';
type TTSPanelProps = {
bookKey: string;
ttsLang: string;
isPlaying: boolean;
timeoutOption: number;
timeoutTimestamp: number;
onTogglePlay: () => void;
onBackward: () => void;
onForward: () => void;
onSetRate: (rate: number) => void;
onGetVoices: (lang: string) => Promise<TTSVoice[]>;
onSetVoice: (voice: string) => void;
onGetVoiceId: () => string;
onSelectTimeout: (value: number) => void;
};
const getTTSTimeoutOptions = (_: TranslationFunc) => {
return [
{
label: _('No Timeout'),
value: 0,
},
{
label: _('{{value}} minute', { value: 1 }),
value: 60,
},
{
label: _('{{value}} minutes', { value: 3 }),
value: 180,
},
{
label: _('{{value}} minutes', { value: 5 }),
value: 300,
},
{
label: _('{{value}} minutes', { value: 10 }),
value: 600,
},
{
label: _('{{value}} minutes', { value: 20 }),
value: 1200,
},
{
label: _('{{value}} minutes', { value: 30 }),
value: 1800,
},
{
label: _('{{value}} minutes', { value: 45 }),
value: 2700,
},
{
label: _('{{value}} hour', { value: 1 }),
value: 3600,
},
{
label: _('{{value}} hours', { value: 2 }),
value: 7200,
},
{
label: _('{{value}} hours', { value: 3 }),
value: 10800,
},
{
label: _('{{value}} hours', { value: 4 }),
value: 14400,
},
{
label: _('{{value}} hours', { value: 6 }),
value: 21600,
},
{
label: _('{{value}} hours', { value: 8 }),
value: 28800,
},
];
};
const getCountdownTime = (timeout: number) => {
const now = Date.now();
if (timeout > now) {
const remainingTime = Math.floor((timeout - now) / 1000);
const minutes = Math.floor(remainingTime / 3600) * 60 + Math.floor((remainingTime % 3600) / 60);
const seconds = remainingTime % 60;
return `${minutes}:${seconds < 10 ? '0' : ''}${seconds}`;
}
return '';
};
const TTSPanel = ({
bookKey,
ttsLang,
isPlaying,
timeoutOption,
timeoutTimestamp,
onTogglePlay,
onBackward,
onForward,
onSetRate,
onGetVoices,
onSetVoice,
onGetVoiceId,
onSelectTimeout,
}: TTSPanelProps) => {
const _ = useTranslation();
const { envConfig } = useEnv();
const { getViewSettings, setViewSettings } = useReaderStore();
const { settings, setSettings, saveSettings } = useSettingsStore();
const viewSettings = getViewSettings(bookKey);
const [voices, setVoices] = useState<TTSVoice[]>([]);
const [rate, setRate] = useState(viewSettings?.ttsRate ?? 1.0);
const [selectedVoice, setSelectedVoice] = useState(viewSettings?.ttsVoice ?? '');
const [timeoutCountdown, setTimeoutCountdown] = useState(() => {
return getCountdownTime(timeoutTimestamp);
});
const defaultIconSize = useDefaultIconSize();
const iconSize32 = useResponsiveSize(32);
const iconSize48 = useResponsiveSize(48);
const handleSetRate = (e: ChangeEvent<HTMLInputElement>) => {
let newRate = parseFloat(e.target.value);
newRate = Math.max(0.2, Math.min(3.0, newRate));
setRate(newRate);
onSetRate(newRate);
const viewSettings = getViewSettings(bookKey)!;
viewSettings.ttsRate = newRate;
settings.globalViewSettings.ttsRate = newRate;
setViewSettings(bookKey, viewSettings);
setSettings(settings);
saveSettings(envConfig, settings);
};
const handleSelectVoice = (voice: string) => {
onSetVoice(voice);
setSelectedVoice(voice);
const viewSettings = getViewSettings(bookKey)!;
viewSettings.ttsVoice = voice;
setViewSettings(bookKey, viewSettings);
};
const updateTimeout = (timeout: number) => {
const now = Date.now();
if (timeout > 0 && timeout < now) {
onSelectTimeout(0);
setTimeoutCountdown('');
} else if (timeout > 0) {
setTimeoutCountdown(getCountdownTime(timeout));
}
};
useEffect(() => {
setTimeout(() => {
updateTimeout(timeoutTimestamp);
}, 1000);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeoutTimestamp, timeoutCountdown]);
useEffect(() => {
const voiceId = onGetVoiceId();
setSelectedVoice(voiceId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const fetchVoices = async () => {
const voices = await onGetVoices(ttsLang);
setVoices(voices);
};
fetchVoices();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ttsLang]);
const timeoutOptions = getTTSTimeoutOptions(_);
return (
<div className='flex w-full flex-col items-center justify-center gap-2 rounded-2xl p-4'>
<div className='flex w-full flex-col items-center gap-0.5'>
<input
className='range'
type='range'
min={0.0}
max={3.0}
step='0.1'
value={rate}
onChange={handleSetRate}
/>
<div className='grid w-full grid-cols-7 text-xs'>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
<span className='text-center'>|</span>
</div>
<div className='grid w-full grid-cols-7 text-xs'>
<span className='text-center'>{_('Slow')}</span>
<span className='text-center'></span>
<span className='text-center'>1.0</span>
<span className='text-center'>1.5</span>
<span className='text-center'>2.0</span>
<span className='text-center'></span>
<span className='text-center'>{_('Fast')}</span>
</div>
</div>
<div className='flex items-center justify-between space-x-2'>
<button onClick={onBackward} className='rounded-full p-1'>
<MdFastRewind size={iconSize32} />
</button>
<button onClick={onTogglePlay} className='rounded-full p-1'>
{isPlaying ? (
<MdPauseCircle size={iconSize48} className='fill-primary' />
) : (
<MdPlayCircle size={iconSize48} className='fill-primary' />
)}
</button>
<button onClick={onForward} className='rounded-full p-1'>
<MdFastForward size={iconSize32} />
</button>
<div className='dropdown dropdown-top'>
<button
tabIndex={0}
className='flex flex-col items-center justify-center rounded-full p-1'
>
<MdAlarm size={iconSize32} />
{timeoutCountdown && (
<span
className={clsx(
'absolute bottom-0 left-1/2 w-12 translate-x-[-50%] translate-y-[80%] px-1',
'bg-primary/80 text-base-100 rounded-full text-center text-xs',
)}
>
{timeoutCountdown}
</span>
)}
</button>
<ul
tabIndex={0}
className={clsx(
'dropdown-content bgcolor-base-200 no-triangle menu menu-vertical rounded-box absolute right-0 z-[1] shadow',
'mt-4 inline max-h-96 w-[200px] overflow-y-scroll',
)}
>
{timeoutOptions.map((option, index) => (
<li key={`${index}-${option.value}`} onClick={() => onSelectTimeout(option.value)}>
<div className='flex items-center px-2'>
<span style={{ minWidth: `${defaultIconSize}px` }}>
{timeoutOption === option.value && <MdCheck className='text-base-content' />}
</span>
<span className={clsx('text-base sm:text-sm')}>{option.label}</span>
</div>
</li>
))}
</ul>
</div>
<div className='dropdown dropdown-top'>
<button tabIndex={0} className='rounded-full p-1'>
<RiVoiceAiFill size={iconSize32} />
</button>
<ul
tabIndex={0}
className={clsx(
'dropdown-content bgcolor-base-200 no-triangle menu menu-vertical rounded-box absolute right-0 z-[1] shadow',
'mt-4 inline max-h-96 w-[250px] overflow-y-scroll',
)}
>
{voices.map((voice, index) => (
<li
key={`${index}-${voice.id}`}
onClick={() => !voice.disabled && handleSelectVoice(voice.id)}
>
<div className='flex items-center px-2'>
<span style={{ minWidth: `${defaultIconSize}px` }}>
{selectedVoice === voice.id && <MdCheck className='text-base-content' />}
</span>
<span className={clsx('text-base sm:text-sm', voice.disabled && 'text-gray-400')}>
{voice.name}
</span>
</div>
</li>
))}
</ul>
</div>
</div>
</div>
);
};
export default TTSPanel;