Feat: voice rate/pitch settings
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user