Merge pull request #52 from thepycoder/feature/better-speed-control
This commit is contained in:
commit
08480f07ed
4 changed files with 141 additions and 36 deletions
|
|
@ -5,56 +5,106 @@ import { ChevronUpDownIcon } from '@/components/icons/Icons';
|
|||
import { useConfig } from '@/contexts/ConfigContext';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export const SpeedControl = ({ setSpeedAndRestart }: {
|
||||
export const SpeedControl = ({
|
||||
setSpeedAndRestart,
|
||||
setAudioPlayerSpeedAndRestart
|
||||
}: {
|
||||
setSpeedAndRestart: (speed: number) => void;
|
||||
setAudioPlayerSpeedAndRestart: (speed: number) => void;
|
||||
}) => {
|
||||
const { voiceSpeed } = useConfig();
|
||||
const [localSpeed, setLocalSpeed] = useState(voiceSpeed);
|
||||
const { voiceSpeed, audioPlayerSpeed } = useConfig();
|
||||
const [localVoiceSpeed, setLocalVoiceSpeed] = useState(voiceSpeed);
|
||||
const [localAudioSpeed, setLocalAudioSpeed] = useState(audioPlayerSpeed);
|
||||
|
||||
// Sync local speed with global state
|
||||
// Sync local speeds with global state
|
||||
useEffect(() => {
|
||||
setLocalSpeed(voiceSpeed);
|
||||
setLocalVoiceSpeed(voiceSpeed);
|
||||
}, [voiceSpeed]);
|
||||
|
||||
// Handler for slider change (updates local state only)
|
||||
const handleSpeedChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalSpeed(parseFloat(event.target.value));
|
||||
useEffect(() => {
|
||||
setLocalAudioSpeed(audioPlayerSpeed);
|
||||
}, [audioPlayerSpeed]);
|
||||
|
||||
// Handler for voice speed slider change (updates local state only)
|
||||
const handleVoiceSpeedChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalVoiceSpeed(parseFloat(event.target.value));
|
||||
}, []);
|
||||
|
||||
// Handler for slider release
|
||||
const handleSpeedChangeComplete = useCallback(() => {
|
||||
if (localSpeed !== voiceSpeed) {
|
||||
setSpeedAndRestart(localSpeed);
|
||||
// Handler for audio player speed slider change (updates local state only)
|
||||
const handleAudioSpeedChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLocalAudioSpeed(parseFloat(event.target.value));
|
||||
}, []);
|
||||
|
||||
// Handler for voice speed slider release
|
||||
const handleVoiceSpeedChangeComplete = useCallback(() => {
|
||||
if (localVoiceSpeed !== voiceSpeed) {
|
||||
setSpeedAndRestart(localVoiceSpeed);
|
||||
}
|
||||
}, [localSpeed, voiceSpeed, setSpeedAndRestart]);
|
||||
}, [localVoiceSpeed, voiceSpeed, setSpeedAndRestart]);
|
||||
|
||||
// Handler for audio player speed slider release
|
||||
const handleAudioSpeedChangeComplete = useCallback(() => {
|
||||
if (localAudioSpeed !== audioPlayerSpeed) {
|
||||
setAudioPlayerSpeedAndRestart(localAudioSpeed);
|
||||
}
|
||||
}, [localAudioSpeed, audioPlayerSpeed, setAudioPlayerSpeedAndRestart]);
|
||||
|
||||
// Display the currently active speed (prioritize audio player speed if different from 1.0)
|
||||
const displaySpeed = localAudioSpeed !== 1.0 ? localAudioSpeed : localVoiceSpeed;
|
||||
|
||||
return (
|
||||
<Popover className="relative">
|
||||
<PopoverButton className="flex items-center space-x-0.5 sm:space-x-1 bg-transparent text-foreground text-xs sm:text-sm focus:outline-none cursor-pointer hover:bg-offbase rounded pl-1.5 sm:pl-2 pr-0.5 sm:pr-1 py-0.5 sm:py-1">
|
||||
<span>{Number.isInteger(localSpeed) ? localSpeed.toString() : localSpeed.toFixed(1)}x</span>
|
||||
<span>{Number.isInteger(displaySpeed) ? displaySpeed.toString() : displaySpeed.toFixed(1)}x</span>
|
||||
<ChevronUpDownIcon className="h-2.5 w-2.5 sm:h-3 sm:w-3" />
|
||||
</PopoverButton>
|
||||
<PopoverPanel anchor="top" className="absolute z-50 bg-base p-3 rounded-md shadow-lg border border-offbase">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs">0.5x</span>
|
||||
<span className="text-xs font-bold">{Number.isInteger(localSpeed) ? localSpeed.toString() : localSpeed.toFixed(1)}x</span>
|
||||
<span className="text-xs">3x</span>
|
||||
<div className="flex flex-col space-y-4">
|
||||
{/* Native Model Speed */}
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="text-xs font-medium text-foreground">Native model speed</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs">0.5x</span>
|
||||
<span className="text-xs font-bold">{Number.isInteger(localVoiceSpeed) ? localVoiceSpeed.toString() : localVoiceSpeed.toFixed(1)}x</span>
|
||||
<span className="text-xs">3x</span>
|
||||
</div>
|
||||
<Input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="3"
|
||||
step="0.1"
|
||||
value={localVoiceSpeed}
|
||||
onChange={handleVoiceSpeedChange}
|
||||
onMouseUp={handleVoiceSpeedChangeComplete}
|
||||
onKeyUp={handleVoiceSpeedChangeComplete}
|
||||
onTouchEnd={handleVoiceSpeedChangeComplete}
|
||||
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Audio Player Speed */}
|
||||
<div className="flex flex-col space-y-2">
|
||||
<div className="text-xs font-medium text-foreground">Audio player speed</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-xs">0.5x</span>
|
||||
<span className="text-xs font-bold">{Number.isInteger(localAudioSpeed) ? localAudioSpeed.toString() : localAudioSpeed.toFixed(1)}x</span>
|
||||
<span className="text-xs">3x</span>
|
||||
</div>
|
||||
<Input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="3"
|
||||
step="0.1"
|
||||
value={localAudioSpeed}
|
||||
onChange={handleAudioSpeedChange}
|
||||
onMouseUp={handleAudioSpeedChangeComplete}
|
||||
onKeyUp={handleAudioSpeedChangeComplete}
|
||||
onTouchEnd={handleAudioSpeedChangeComplete}
|
||||
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
type="range"
|
||||
min="0.5"
|
||||
max="3"
|
||||
step="0.1"
|
||||
value={localSpeed}
|
||||
onChange={handleSpeedChange}
|
||||
onMouseUp={handleSpeedChangeComplete}
|
||||
onKeyUp={handleSpeedChangeComplete}
|
||||
onTouchEnd={handleSpeedChangeComplete}
|
||||
className="w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent"
|
||||
/>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
|
@ -24,6 +24,7 @@ export default function TTSPlayer({ currentPage, numPages }: {
|
|||
skipBackward,
|
||||
isProcessing,
|
||||
setSpeedAndRestart,
|
||||
setAudioPlayerSpeedAndRestart,
|
||||
setVoiceAndRestart,
|
||||
availableVoices,
|
||||
skipToLocation,
|
||||
|
|
@ -33,7 +34,10 @@ export default function TTSPlayer({ currentPage, numPages }: {
|
|||
<div className={`fixed bottom-4 left-1/2 transform -translate-x-1/2 z-49 transition-opacity duration-300`}>
|
||||
<div className="bg-base dark:bg-base rounded-full shadow-lg px-3 sm:px-4 py-0.5 sm:py-1 flex items-center space-x-0.5 sm:space-x-1 relative scale-90 sm:scale-100 border border-offbase">
|
||||
{/* Speed control */}
|
||||
<SpeedControl setSpeedAndRestart={setSpeedAndRestart} />
|
||||
<SpeedControl
|
||||
setSpeedAndRestart={setSpeedAndRestart}
|
||||
setAudioPlayerSpeedAndRestart={setAudioPlayerSpeedAndRestart}
|
||||
/>
|
||||
|
||||
{/* Page Navigation */}
|
||||
{currentPage && numPages && (
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ type ConfigValues = {
|
|||
baseUrl: string;
|
||||
viewType: ViewType;
|
||||
voiceSpeed: number;
|
||||
audioPlayerSpeed: number;
|
||||
voice: string;
|
||||
skipBlank: boolean;
|
||||
epubTheme: boolean;
|
||||
|
|
@ -29,6 +30,7 @@ interface ConfigContextType {
|
|||
baseUrl: string;
|
||||
viewType: ViewType;
|
||||
voiceSpeed: number;
|
||||
audioPlayerSpeed: number;
|
||||
voice: string;
|
||||
skipBlank: boolean;
|
||||
epubTheme: boolean;
|
||||
|
|
@ -58,6 +60,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
const [baseUrl, setBaseUrl] = useState<string>('');
|
||||
const [viewType, setViewType] = useState<ViewType>('single');
|
||||
const [voiceSpeed, setVoiceSpeed] = useState<number>(1);
|
||||
const [audioPlayerSpeed, setAudioPlayerSpeed] = useState<number>(1);
|
||||
const [voice, setVoice] = useState<string>('af_sarah');
|
||||
const [skipBlank, setSkipBlank] = useState<boolean>(true);
|
||||
const [epubTheme, setEpubTheme] = useState<boolean>(false);
|
||||
|
|
@ -83,6 +86,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
const cachedBaseUrl = await getItem('baseUrl');
|
||||
const cachedViewType = await getItem('viewType');
|
||||
const cachedVoiceSpeed = await getItem('voiceSpeed');
|
||||
const cachedAudioPlayerSpeed = await getItem('audioPlayerSpeed');
|
||||
const cachedVoice = await getItem('voice');
|
||||
const cachedSkipBlank = await getItem('skipBlank');
|
||||
const cachedEpubTheme = await getItem('epubTheme');
|
||||
|
|
@ -106,6 +110,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
// Set the other values with defaults
|
||||
setViewType((cachedViewType || 'single') as ViewType);
|
||||
setVoiceSpeed(parseFloat(cachedVoiceSpeed || '1'));
|
||||
setAudioPlayerSpeed(parseFloat(cachedAudioPlayerSpeed || '1'));
|
||||
setVoice(cachedVoice || 'af_sarah');
|
||||
setSkipBlank(cachedSkipBlank === 'false' ? false : true);
|
||||
setEpubTheme(cachedEpubTheme === 'true');
|
||||
|
|
@ -136,6 +141,12 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
if (cachedTTSInstructions === null) {
|
||||
await setItem('ttsInstructions', '');
|
||||
}
|
||||
if (!cachedVoiceSpeed) {
|
||||
await setItem('voiceSpeed', '1');
|
||||
}
|
||||
if (!cachedAudioPlayerSpeed) {
|
||||
await setItem('audioPlayerSpeed', '1');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing:', error);
|
||||
|
|
@ -151,7 +162,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
* Updates multiple configuration values simultaneously
|
||||
* Only saves API credentials if they are explicitly set
|
||||
*/
|
||||
const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => {
|
||||
const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string; viewType: ViewType }>) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
if (newConfig.apiKey !== undefined || newConfig.apiKey !== '') {
|
||||
|
|
@ -205,6 +216,9 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
case 'voiceSpeed':
|
||||
setVoiceSpeed(value as number);
|
||||
break;
|
||||
case 'audioPlayerSpeed':
|
||||
setAudioPlayerSpeed(value as number);
|
||||
break;
|
||||
case 'voice':
|
||||
setVoice(value as string);
|
||||
break;
|
||||
|
|
@ -247,6 +261,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
|
|||
baseUrl,
|
||||
viewType,
|
||||
voiceSpeed,
|
||||
audioPlayerSpeed,
|
||||
voice,
|
||||
skipBlank,
|
||||
epubTheme,
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ interface TTSContextType {
|
|||
setText: (text: string, shouldPause?: boolean) => void;
|
||||
setCurrDocPages: (num: number | undefined) => void;
|
||||
setSpeedAndRestart: (speed: number) => void;
|
||||
setAudioPlayerSpeedAndRestart: (speed: number) => void;
|
||||
setVoiceAndRestart: (voice: string) => void;
|
||||
skipToLocation: (location: string | number, shouldPause?: boolean) => void;
|
||||
registerLocationChangeHandler: (handler: (location: string | number) => void) => void; // EPUB-only: Handles chapter navigation
|
||||
|
|
@ -98,6 +99,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
baseUrl: openApiBaseUrl,
|
||||
isLoading: configIsLoading,
|
||||
voiceSpeed,
|
||||
audioPlayerSpeed,
|
||||
voice: configVoice,
|
||||
ttsModel: configTTSModel,
|
||||
ttsInstructions: configTTSInstructions,
|
||||
|
|
@ -141,6 +143,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [activeHowl, setActiveHowl] = useState<Howl | null>(null);
|
||||
const [speed, setSpeed] = useState(voiceSpeed);
|
||||
const [audioSpeed, setAudioSpeed] = useState(audioPlayerSpeed);
|
||||
const [voice, setVoice] = useState(configVoice);
|
||||
const [ttsModel, setTTSModel] = useState(configTTSModel);
|
||||
const [ttsInstructions, setTTSInstructions] = useState(configTTSInstructions);
|
||||
|
|
@ -387,7 +390,8 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
const updateVoiceAndSpeed = useCallback(() => {
|
||||
setVoice(configVoice);
|
||||
setSpeed(voiceSpeed);
|
||||
}, [configVoice, voiceSpeed]);
|
||||
setAudioSpeed(audioPlayerSpeed);
|
||||
}, [configVoice, voiceSpeed, audioPlayerSpeed]);
|
||||
|
||||
/**
|
||||
* Initializes configuration and fetches available voices
|
||||
|
|
@ -570,6 +574,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
html5: true,
|
||||
preload: true,
|
||||
pool: 5,
|
||||
rate: audioSpeed,
|
||||
onplay: () => {
|
||||
setIsProcessing(false);
|
||||
if ('mediaSession' in navigator) {
|
||||
|
|
@ -666,7 +671,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
advance();
|
||||
return null;
|
||||
}
|
||||
}, [isPlaying, advance, activeHowl, processSentence]);
|
||||
}, [isPlaying, advance, activeHowl, processSentence, audioSpeed]);
|
||||
|
||||
const playAudio = useCallback(async () => {
|
||||
const howl = await playSentenceWithHowl(sentences[currentIndex]);
|
||||
|
|
@ -822,6 +827,35 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
});
|
||||
}, [abortAudio, updateConfigKey, audioCache, isPlaying]);
|
||||
|
||||
/**
|
||||
* Sets the audio player speed and restarts the playback
|
||||
*
|
||||
* @param {number} newSpeed - The new audio player speed to set
|
||||
*/
|
||||
const setAudioPlayerSpeedAndRestart = useCallback((newSpeed: number) => {
|
||||
const wasPlaying = isPlaying;
|
||||
|
||||
// Set a flag to prevent double audio requests during config update
|
||||
setIsProcessing(true);
|
||||
|
||||
// First stop any current playback
|
||||
setIsPlaying(false);
|
||||
abortAudio(true); // Clear pending requests since speed changed
|
||||
setActiveHowl(null);
|
||||
|
||||
// Update audio speed and config
|
||||
setAudioSpeed(newSpeed);
|
||||
|
||||
// Update config after state changes
|
||||
updateConfigKey('audioPlayerSpeed', newSpeed).then(() => {
|
||||
setIsProcessing(false);
|
||||
// Resume playback if it was playing before
|
||||
if (wasPlaying) {
|
||||
setIsPlaying(true);
|
||||
}
|
||||
});
|
||||
}, [abortAudio, updateConfigKey, isPlaying]);
|
||||
|
||||
/**
|
||||
* Provides the TTS context value to child components
|
||||
*/
|
||||
|
|
@ -843,6 +877,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
setText,
|
||||
setCurrDocPages,
|
||||
setSpeedAndRestart,
|
||||
setAudioPlayerSpeedAndRestart,
|
||||
setVoiceAndRestart,
|
||||
skipToLocation,
|
||||
registerLocationChangeHandler,
|
||||
|
|
@ -866,6 +901,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement
|
|||
setText,
|
||||
setCurrDocPages,
|
||||
setSpeedAndRestart,
|
||||
setAudioPlayerSpeedAndRestart,
|
||||
setVoiceAndRestart,
|
||||
skipToLocation,
|
||||
registerLocationChangeHandler,
|
||||
|
|
|
|||
Loading…
Reference in a new issue