Feat: voice rate/pitch settings

This commit is contained in:
nyagami
2024-07-15 21:47:33 +07:00
parent 9d35f6d303
commit 19ce7da32e
5 changed files with 131 additions and 40 deletions
+10 -5
View File
@@ -9,14 +9,10 @@ const volumeIcon =
'<svg height="24" viewBox="0 -960 960 960" width="24"><path d="M560-131v-82q90-26 145-100t55-168q0-94-55-168T560-749v-82q124 28 202 125.5T840-481q0 127-78 224.5T560-131ZM120-360v-240h160l200-200v640L280-360H120Zm440 40v-322q47 22 73.5 66t26.5 96q0 51-26.5 94.5T560-320Z"/></svg>';
const selectAllIcon =
'<svg height="24" viewBox="0 -960 960 960" width="24"><path d="M280-280v-400h400v400H280Zm80-80h240v-240H360v240ZM200-200v80q-33 0-56.5-23.5T120-200h80Zm-80-80v-80h80v80h-80Zm0-160v-80h80v80h-80Zm0-160v-80h80v80h-80Zm80-160h-80q0-33 23.5-56.5T200-840v80Zm80 640v-80h80v80h-80Zm0-640v-80h80v80h-80Zm160 640v-80h80v80h-80Zm0-640v-80h80v80h-80Zm160 640v-80h80v80h-80Zm0-640v-80h80v80h-80Zm160 640v-80h80q0 33-23.5 56.5T760-120Zm0-160v-80h80v80h-80Zm0-160v-80h80v80h-80Zm0-160v-80h80v80h-80Zm0-160v-80q33 0 56.5 23.5T840-760h-80Z"/></svg>';
const translateIcon =
'<svg height="24" viewBox="0 -960 960 960" width="24"><path d="m476-80 182-480h84L924-80h-84l-43-122H603L560-80h-84ZM160-200l-56-56 202-202q-35-35-63.5-80T190-640h84q20 39 40 68t48 58q33-33 68.5-92.5T484-720H40v-80h280v-80h80v80h280v80H564q-21 72-63 148t-83 116l96 98-30 82-122-125-202 201Zm468-72h144l-72-204-72 204Z"/></svg>';
const resumeIcon =
'<svg height="24" viewBox="0 -960 960 960" width="24"><path d="M240-240v-480h80v480h-80Zm160 0 400-240-400-240v480Z"/></svg>';
const pauseIcon =
'<svg height="24" viewBox="0 -960 960 960" width="24"><path d="M560-200v-560h160v560H560Zm-320 0v-560h160v560H240Z"/></svg>';
const stopIcon =
'<svg height="24" viewBox="0 -960 960 960" width="24"><path d="M320-320h320v-320H320v320ZM480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Z"/></svg>';
const selectVolumeIcon =
'<svg height="24" viewBox="0 -960 960 960" width="24"><path d="M560-131v-82q90-26 145-100t55-168q0-94-55-168T560-749v-82q124 28 202 125.5T840-481q0 127-78 224.5T560-131Zm-80-29L280-360H120v-240h160l200-200v640Zm80-160v-322q47 22 73.5 66t26.5 96q0 51-26.5 94.5T560-320ZM40-680v-240h240v80H120v160H40ZM680-40v-80h160v-160h80v240H680Z"/></svg>';
/**
@@ -446,7 +442,6 @@ class TextToSpeech {
if (!this.leaf.hasChildNodes()) {
return false;
}
const a = [];
for (let i = 0; i < this.leaf.childNodes.length; i++) {
if (
!this.readableNodeNames.includes(this.leaf.childNodes.item(i).nodeName)
@@ -598,7 +593,17 @@ class ContextMenu {
});
}
closeMenu() {
if (this.isOpened) {
this.isOpened = false;
this.contextMenu.remove();
}
}
init() {
this.reader.chapter.addEventListener('click', () => {
this.closeMenu(this.contextMenu);
});
document.addEventListener('contextmenu', e => {
e.preventDefault();
if (e.target instanceof HTMLImageElement) {
+13 -19
View File
@@ -51,6 +51,18 @@ type WebViewReaderProps = {
pageReader: boolean;
};
const onLogMessage = (payload: { nativeEvent: { data: string } }) => {
let dataPayload;
try {
dataPayload = JSON.parse(payload.nativeEvent.data);
} catch (e) {}
if (dataPayload) {
if (dataPayload.type === 'console') {
console.info(`[Console] ${JSON.stringify(dataPayload.msg, null, 2)}`);
}
}
};
const WebViewReader: React.FC<WebViewReaderProps> = props => {
const {
data: { chapter, novel },
@@ -139,24 +151,6 @@ const WebViewReader: React.FC<WebViewReaderProps> = props => {
console.warn = console.log;
console.error = console.log;
`;
//@ts-ignore
const onMessage = payload => {
let dataPayload;
try {
dataPayload = JSON.parse(payload.nativeEvent.data);
} catch (e) {}
if (dataPayload) {
if (dataPayload.type === 'console') {
// eslint-disable-next-line no-console
console.info(`[Console] ${JSON.stringify(dataPayload.msg, null, 2)}`);
} else if (false) {
if (dataPayload.type !== 'save') {
console.log(dataPayload);
}
}
}
};
return (
<WebView
@@ -170,7 +164,7 @@ const WebViewReader: React.FC<WebViewReaderProps> = props => {
javaScriptEnabled={true}
onLayout={async () => onLayout()}
onMessage={(ev: { nativeEvent: { data: string } }) => {
__DEV__ && onMessage(ev);
__DEV__ && onLogMessage(ev);
const event: WebViewPostEvent = JSON.parse(ev.nativeEvent.data);
switch (event.type) {
case 'hide':
@@ -1,33 +1,35 @@
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import { StyleSheet } from 'react-native';
import { Portal, Modal, overlay, TextInput } from 'react-native-paper';
import {
Portal,
Modal,
overlay,
TextInput,
ActivityIndicator,
} from 'react-native-paper';
import { RadioButton } from '@components/RadioButton/RadioButton';
import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
import { Voice, getAvailableVoicesAsync } from 'expo-speech';
import { Voice } from 'expo-speech';
import { FlashList } from '@shopify/flash-list';
interface VoicePickerModalProps {
visible: boolean;
onDismiss: () => void;
voices: Voice[];
}
const VoicePickerModal: React.FC<VoicePickerModalProps> = ({
onDismiss,
visible,
voices,
}) => {
const theme = useTheme();
const [voices, setVoices] = useState<Voice[]>([]);
const [searchedVoices, setSearchedVoices] = useState<Voice[]>([]);
const [searchText, setSearchText] = useState('');
const { setChapterReaderSettings, tts } = useChapterReaderSettings();
useEffect(() => {
getAvailableVoicesAsync().then(res => {
res.sort((a, b) => a.name.localeCompare(b.name));
setVoices([{ name: 'System', language: 'System' } as Voice, ...res]);
});
}, []);
return (
<Portal>
<Modal
@@ -76,6 +78,13 @@ const VoicePickerModal: React.FC<VoicePickerModalProps> = ({
keyExtractor={item => item.identifier || 'system'}
estimatedItemSize={64}
removeClippedSubviews={true}
ListEmptyComponent={
<ActivityIndicator
size={24}
style={{ marginTop: 16 }}
color={theme.primary}
/>
}
/>
</Modal>
</Portal>
@@ -1,13 +1,24 @@
import { List } from '@components';
import { Button, List } from '@components';
import { useChapterReaderSettings, useTheme } from '@hooks/persisted';
import React from 'react';
import React, { useEffect, useState } from 'react';
import VoicePickerModal from '../Modals/VoicePickerModal';
import { useBoolean } from '@hooks';
import { Portal } from 'react-native-paper';
import { StyleSheet, View, Text } from 'react-native';
import Slider from '@react-native-community/slider';
import { getAvailableVoicesAsync, Voice } from 'expo-speech';
export default function TextToSpeechSettings() {
const theme = useTheme();
const { tts } = useChapterReaderSettings();
const [voices, setVoices] = useState<Voice[]>([]);
useEffect(() => {
getAvailableVoicesAsync().then(res => {
res.sort((a, b) => a.name.localeCompare(b.name));
setVoices([{ name: 'System', language: 'System' } as Voice, ...res]);
});
}, []);
const { tts, setChapterReaderSettings } = useChapterReaderSettings();
const {
value: voiceModalVisible,
setTrue: showVoiceModal,
@@ -15,19 +26,87 @@ export default function TextToSpeechSettings() {
} = useBoolean();
return (
<>
<List.SubHeader theme={theme}>Text to Speech</List.SubHeader>
<View style={styles.row}>
<List.SubHeader theme={theme}>Text to Speech</List.SubHeader>
<Button
mode="text"
onPress={() => {
setChapterReaderSettings({
tts: {
pitch: 1,
rate: 1,
voice: { name: 'System', language: 'System' } as Voice,
},
});
}}
title="Reset"
icon="reload"
/>
</View>
<List.Item
title={'TTS voice'}
description={tts?.voice?.name || 'System'}
onPress={showVoiceModal}
theme={theme}
/>
<List.Section>
<Text style={[styles.label, { color: theme.onSurface }]}>
Voice rate
</Text>
<Slider
style={styles.slider}
value={tts?.rate}
minimumValue={0.1}
maximumValue={5}
step={0.1}
minimumTrackTintColor={theme.primary}
maximumTrackTintColor={theme.surfaceVariant}
thumbTintColor={theme.primary}
onSlidingComplete={value =>
setChapterReaderSettings({ tts: { ...tts, rate: value } })
}
/>
<Text style={[styles.label, { color: theme.onSurface }]}>
Voice pitch
</Text>
<Slider
style={styles.slider}
value={tts?.pitch}
minimumValue={0.1}
maximumValue={5}
step={0.1}
minimumTrackTintColor={theme.primary}
maximumTrackTintColor={theme.surfaceVariant}
thumbTintColor={theme.primary}
onSlidingComplete={value =>
setChapterReaderSettings({ tts: { ...tts, pitch: value } })
}
/>
</List.Section>
<View style={{ height: 16 }} />
<Portal>
<VoicePickerModal
visible={voiceModalVisible}
onDismiss={hideVoiceModal}
voices={voices}
/>
</Portal>
</>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
label: {
textAlign: 'center',
fontSize: 16,
},
slider: {
flex: 1,
height: 40,
},
});
@@ -1,5 +1,5 @@
import { View, ScrollView, StatusBar, Dimensions } from 'react-native';
import React, { useMemo, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigation } from '@react-navigation/native';
import WebView from 'react-native-webview';
@@ -139,7 +139,11 @@ const SettingsReaderScreen = () => {
`;
const readerBackgroundColor = readerSettings.theme;
useEffect(() => {
return () => {
Speech.stop();
};
}, []);
return (
<>
<Appbar