react-native-maplibre-lite 0.2.5 → 0.2.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -12
- package/lib/module/components/MapView.js +19 -22
- package/lib/module/components/MapView.js.map +1 -1
- package/lib/module/components/NavigatorVoiceControl.js +9 -10
- package/lib/module/components/NavigatorVoiceControl.js.map +1 -1
- package/lib/module/components/navigatorVoicePlayer.js +2 -4
- package/lib/module/components/navigatorVoicePlayer.js.map +1 -1
- package/lib/module/components/types.js +6 -2
- package/lib/module/components/types.js.map +1 -1
- package/lib/module/components/useNavigatorTts.js +154 -0
- package/lib/module/components/useNavigatorTts.js.map +1 -0
- package/lib/module/components/webMapBuild.js +1 -1
- package/lib/module/components/webMapBuild.js.map +1 -1
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/MapView.d.ts +8 -5
- package/lib/typescript/src/components/MapView.d.ts.map +1 -1
- package/lib/typescript/src/components/NavigatorVoiceControl.d.ts +6 -7
- package/lib/typescript/src/components/NavigatorVoiceControl.d.ts.map +1 -1
- package/lib/typescript/src/components/navigatorVoicePlayer.d.ts.map +1 -1
- package/lib/typescript/src/components/types.d.ts +8 -4
- package/lib/typescript/src/components/types.d.ts.map +1 -1
- package/lib/typescript/src/components/useNavigatorTts.d.ts +28 -0
- package/lib/typescript/src/components/useNavigatorTts.d.ts.map +1 -0
- package/lib/typescript/src/components/webMapBuild.d.ts +1 -1
- package/lib/typescript/src/components/webMapBuild.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +1 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/resources/map.html +42 -43
- package/src/components/MapView.tsx +34 -25
- package/src/components/NavigatorVoiceControl.tsx +13 -14
- package/src/components/navigatorVoicePlayer.tsx +2 -4
- package/src/components/types.ts +13 -4
- package/src/components/useNavigatorTts.ts +195 -0
- package/src/components/webMapBuild.ts +1 -1
- package/src/index.tsx +2 -0
- package/lib/module/components/navigatorVoiceCatalog.js +0 -261
- package/lib/module/components/navigatorVoiceCatalog.js.map +0 -1
- package/lib/module/components/navigatorVoiceKeys.js +0 -14
- package/lib/module/components/navigatorVoiceKeys.js.map +0 -1
- package/lib/module/components/useNavigatorVoice.js +0 -78
- package/lib/module/components/useNavigatorVoice.js.map +0 -1
- package/lib/typescript/src/components/navigatorVoiceCatalog.d.ts +0 -50
- package/lib/typescript/src/components/navigatorVoiceCatalog.d.ts.map +0 -1
- package/lib/typescript/src/components/navigatorVoiceKeys.d.ts +0 -10
- package/lib/typescript/src/components/navigatorVoiceKeys.d.ts.map +0 -1
- package/lib/typescript/src/components/useNavigatorVoice.d.ts +0 -20
- package/lib/typescript/src/components/useNavigatorVoice.d.ts.map +0 -1
- package/src/components/navigatorVoiceCatalog.ts +0 -316
- package/src/components/navigatorVoiceKeys.ts +0 -132
- package/src/components/useNavigatorVoice.ts +0 -96
|
@@ -26,10 +26,9 @@ import { NavigatorHud } from './NavigatorHud';
|
|
|
26
26
|
import { NavigatorRecenterButton } from './NavigatorRecenterButton';
|
|
27
27
|
import { NavigatorVoiceControl } from './NavigatorVoiceControl';
|
|
28
28
|
import { resolveNavigatorChromeTheme } from './navigatorChromeTheme';
|
|
29
|
-
import { keysToClipUrls, volumeLevelToGain } from './navigatorVoiceCatalog';
|
|
30
29
|
import { NavigatorVoicePlayer, type NavigatorVoicePlayerRef } from './navigatorVoicePlayer';
|
|
31
30
|
import { navigatorUiStrings } from './navigatorVoiceStrings';
|
|
32
|
-
import {
|
|
31
|
+
import { useNavigatorTts, volumeLevelToGain } from './useNavigatorTts';
|
|
33
32
|
import {
|
|
34
33
|
type EventParams,
|
|
35
34
|
type MapLiteInitParams,
|
|
@@ -50,6 +49,8 @@ import {
|
|
|
50
49
|
type NavigatorProfile,
|
|
51
50
|
type NavigatorPositionSetParams,
|
|
52
51
|
type NavigatorRouteSetParams,
|
|
52
|
+
type NavigatorTtsHandler,
|
|
53
|
+
type NavigatorTtsVoices,
|
|
53
54
|
type NavigatorVoicePlayParams,
|
|
54
55
|
type PolygonProps,
|
|
55
56
|
type PolylineProps,
|
|
@@ -117,11 +118,14 @@ interface MapViewProps {
|
|
|
117
118
|
*/
|
|
118
119
|
navigatorChrome?: NavigatorChromeParams;
|
|
119
120
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* стороне. Без него озвучка и FAB выбора голоса отключены.
|
|
121
|
+
* Голоса TTS: ключ → подпись в UI. Вместе с `ttsHandler` включает
|
|
122
|
+
* озвучку и FAB выбора голоса.
|
|
123
123
|
*/
|
|
124
|
-
|
|
124
|
+
ttsVoices?: NavigatorTtsVoices;
|
|
125
|
+
/**
|
|
126
|
+
* Синтез фразы: текст + ключ голоса → URL аудио для воспроизведения.
|
|
127
|
+
*/
|
|
128
|
+
ttsHandler?: NavigatorTtsHandler;
|
|
125
129
|
/**
|
|
126
130
|
* Dev-only: автоматически вести маркер по построенному маршруту
|
|
127
131
|
* (симуляция поездки для отладки). Требует `navigator: true`. Пока
|
|
@@ -249,9 +253,14 @@ export const MapView = forwardRef<MapViewRef, MapViewProps>((props, ref) => {
|
|
|
249
253
|
const [hudState, setHudState] = useState<NavigatorHudState | null>(null);
|
|
250
254
|
const voicePlayerRef = useRef<NavigatorVoicePlayerRef | null>(null);
|
|
251
255
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
256
|
+
const ttsEnabled =
|
|
257
|
+
props.navigator === true &&
|
|
258
|
+
props.ttsHandler != null &&
|
|
259
|
+
Object.keys(props.ttsVoices ?? {}).length > 0;
|
|
260
|
+
const tts = useNavigatorTts(
|
|
261
|
+
ttsEnabled ? props.ttsVoices : undefined,
|
|
262
|
+
ttsEnabled ? props.ttsHandler : undefined
|
|
263
|
+
);
|
|
255
264
|
|
|
256
265
|
const chromeTheme = useMemo(
|
|
257
266
|
() => resolveNavigatorChromeTheme(props.navigatorChrome),
|
|
@@ -263,8 +272,8 @@ export const MapView = forwardRef<MapViewRef, MapViewProps>((props, ref) => {
|
|
|
263
272
|
);
|
|
264
273
|
|
|
265
274
|
useEffect(() => {
|
|
266
|
-
voicePlayerRef.current?.setVolume(volumeLevelToGain(
|
|
267
|
-
}, [
|
|
275
|
+
voicePlayerRef.current?.setVolume(volumeLevelToGain(tts.volumeLevel));
|
|
276
|
+
}, [tts.volumeLevel]);
|
|
268
277
|
|
|
269
278
|
const handleNavigatorVoice = (params: NavigatorVoicePlayParams) => {
|
|
270
279
|
const player = voicePlayerRef.current;
|
|
@@ -273,10 +282,10 @@ export const MapView = forwardRef<MapViewRef, MapViewProps>((props, ref) => {
|
|
|
273
282
|
player.stop();
|
|
274
283
|
return;
|
|
275
284
|
}
|
|
276
|
-
if (params.action
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
285
|
+
if (params.action !== 'play' || !params.text?.trim()) return;
|
|
286
|
+
void tts.synthesize(params.text).then((url) => {
|
|
287
|
+
if (url) player.playUrls([url]);
|
|
288
|
+
});
|
|
280
289
|
};
|
|
281
290
|
|
|
282
291
|
const coordsInMapRef = useRef<Record<string, [number, number][]>>({});
|
|
@@ -703,23 +712,23 @@ export const MapView = forwardRef<MapViewRef, MapViewProps>((props, ref) => {
|
|
|
703
712
|
accessibilityLabel={navigatorStrings.recenterAria}
|
|
704
713
|
onPress={recenterNavigatorCamera}
|
|
705
714
|
/>
|
|
706
|
-
{
|
|
715
|
+
{ttsEnabled && (
|
|
707
716
|
<NavigatorVoiceControl
|
|
708
717
|
theme={chromeTheme}
|
|
709
718
|
strings={navigatorStrings}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
voiceEnabled={
|
|
713
|
-
volumeLevel={
|
|
714
|
-
onSelectVoice={
|
|
715
|
-
onSelectVolume={
|
|
716
|
-
onDisable={
|
|
719
|
+
voiceEntries={tts.voiceEntries}
|
|
720
|
+
selectedVoiceKey={tts.selectedVoiceKey}
|
|
721
|
+
voiceEnabled={tts.voiceEnabled}
|
|
722
|
+
volumeLevel={tts.volumeLevel}
|
|
723
|
+
onSelectVoice={tts.selectVoice}
|
|
724
|
+
onSelectVolume={tts.selectVolume}
|
|
725
|
+
onDisable={tts.disableVoice}
|
|
717
726
|
/>
|
|
718
727
|
)}
|
|
719
|
-
{
|
|
728
|
+
{ttsEnabled && (
|
|
720
729
|
<NavigatorVoicePlayer
|
|
721
730
|
ref={voicePlayerRef}
|
|
722
|
-
initialVolume={volumeLevelToGain(
|
|
731
|
+
initialVolume={volumeLevelToGain(tts.volumeLevel)}
|
|
723
732
|
/>
|
|
724
733
|
)}
|
|
725
734
|
</>
|
|
@@ -3,19 +3,18 @@ import { Pressable, ScrollView, Text, View } from 'react-native';
|
|
|
3
3
|
|
|
4
4
|
import { NavigatorVoiceIcon } from './navigatorManeuverIcon';
|
|
5
5
|
import type { NavigatorChromeTheme } from './navigatorChromeTheme';
|
|
6
|
-
import type { VoiceCatalogEntry, VoiceVolumeLevel } from './navigatorVoiceCatalog';
|
|
7
6
|
import type { NavigatorUiStrings } from './navigatorVoiceStrings';
|
|
7
|
+
import type { VoiceVolumeLevel } from './useNavigatorTts';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* FAB выбора голоса + меню (список голосов, громкость 1–5, «отключить»).
|
|
11
|
-
* Порт DOM-меню из веб-навигатора.
|
|
12
|
-
* `useNavigatorVoice`; компонент только презентационный.
|
|
11
|
+
* Порт DOM-меню из веб-навигатора. Персист и TTS — в `useNavigatorTts`.
|
|
13
12
|
*/
|
|
14
13
|
export function NavigatorVoiceControl({
|
|
15
14
|
theme,
|
|
16
15
|
strings,
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
voiceEntries,
|
|
17
|
+
selectedVoiceKey,
|
|
19
18
|
voiceEnabled,
|
|
20
19
|
volumeLevel,
|
|
21
20
|
onSelectVoice,
|
|
@@ -24,17 +23,17 @@ export function NavigatorVoiceControl({
|
|
|
24
23
|
}: {
|
|
25
24
|
theme: NavigatorChromeTheme;
|
|
26
25
|
strings: NavigatorUiStrings;
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
voiceEntries: [string, string][];
|
|
27
|
+
selectedVoiceKey: string | null;
|
|
29
28
|
voiceEnabled: boolean;
|
|
30
29
|
volumeLevel: VoiceVolumeLevel;
|
|
31
|
-
onSelectVoice: (
|
|
30
|
+
onSelectVoice: (voiceKey: string) => void;
|
|
32
31
|
onSelectVolume: (level: VoiceVolumeLevel) => void;
|
|
33
32
|
onDisable: () => void;
|
|
34
33
|
}) {
|
|
35
34
|
const [open, setOpen] = useState(false);
|
|
36
35
|
|
|
37
|
-
if (
|
|
36
|
+
if (voiceEntries.length === 0) return null;
|
|
38
37
|
|
|
39
38
|
return (
|
|
40
39
|
<>
|
|
@@ -84,15 +83,15 @@ export function NavigatorVoiceControl({
|
|
|
84
83
|
}}
|
|
85
84
|
>
|
|
86
85
|
<ScrollView style={{ maxHeight: 280 }}>
|
|
87
|
-
{
|
|
88
|
-
const active = voiceEnabled &&
|
|
86
|
+
{voiceEntries.map(([voiceKey, label]) => {
|
|
87
|
+
const active = voiceEnabled && voiceKey === selectedVoiceKey;
|
|
89
88
|
return (
|
|
90
89
|
<Pressable
|
|
91
|
-
key={
|
|
90
|
+
key={voiceKey}
|
|
92
91
|
accessibilityRole="menuitem"
|
|
93
92
|
onPress={() => {
|
|
94
93
|
setOpen(false);
|
|
95
|
-
onSelectVoice(
|
|
94
|
+
onSelectVoice(voiceKey);
|
|
96
95
|
}}
|
|
97
96
|
style={{ paddingVertical: 10, paddingHorizontal: 16 }}
|
|
98
97
|
>
|
|
@@ -103,7 +102,7 @@ export function NavigatorVoiceControl({
|
|
|
103
102
|
fontWeight: active ? '600' : '400',
|
|
104
103
|
}}
|
|
105
104
|
>
|
|
106
|
-
{
|
|
105
|
+
{label}
|
|
107
106
|
</Text>
|
|
108
107
|
</Pressable>
|
|
109
108
|
);
|
|
@@ -9,10 +9,8 @@ import {
|
|
|
9
9
|
import Video, { type VideoRef } from 'react-native-video';
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* веб присылает список ключей фраз, нативная часть собирает из них URL и
|
|
15
|
-
* проигрывает их по очереди с короткой паузой между клипами.
|
|
12
|
+
* Проигрыватель озвучки навигатора на `react-native-video`.
|
|
13
|
+
* Нативная часть передаёт URL аудио (из `ttsHandler`) и проигрывает очередь.
|
|
16
14
|
*
|
|
17
15
|
* Один экземпляр `<Video>` на всю сессию — между клипами меняется только source
|
|
18
16
|
* (или seek(0) при повторе того же URL), без remount.
|
package/src/components/types.ts
CHANGED
|
@@ -154,17 +154,26 @@ export type NavigatorHudState = {
|
|
|
154
154
|
}
|
|
155
155
|
|
|
156
156
|
/** Действие озвучки в событии `navigatorVoice`. */
|
|
157
|
-
export type NavigatorVoiceAction = 'play' | '
|
|
157
|
+
export type NavigatorVoiceAction = 'play' | 'stop'
|
|
158
158
|
|
|
159
159
|
/**
|
|
160
|
-
* Web → RN: `navigatorVoice` event. Веб
|
|
161
|
-
* нативная часть
|
|
160
|
+
* Web → RN: `navigatorVoice` event. Веб собирает полную фразу и шлёт текст;
|
|
161
|
+
* нативная часть вызывает `ttsHandler` и воспроизводит URL аудио.
|
|
162
162
|
*/
|
|
163
163
|
export type NavigatorVoicePlayParams = {
|
|
164
164
|
action: NavigatorVoiceAction,
|
|
165
|
-
|
|
165
|
+
text?: string,
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
/** Голоса TTS: ключ → подпись в UI выбора голоса. */
|
|
169
|
+
export type NavigatorTtsVoices = Record<string, string>
|
|
170
|
+
|
|
171
|
+
/** Колбэк TTS: текст фразы + ключ голоса → URL аудио для воспроизведения. */
|
|
172
|
+
export type NavigatorTtsHandler = (
|
|
173
|
+
text: string,
|
|
174
|
+
voiceKey: string
|
|
175
|
+
) => Promise<string>
|
|
176
|
+
|
|
168
177
|
export type MapLiteWebError = {
|
|
169
178
|
target: string,
|
|
170
179
|
message: string,
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
4
|
+
|
|
5
|
+
import type { NavigatorTtsHandler, NavigatorTtsVoices } from './types';
|
|
6
|
+
|
|
7
|
+
const LS_TTS_VOICE_PREF = 'maplite-navigator-tts-voice-pref';
|
|
8
|
+
const LS_VOICE_VOLUME = 'maplite-navigator-voice-volume';
|
|
9
|
+
|
|
10
|
+
export type VoiceVolumeLevel = 1 | 2 | 3 | 4 | 5;
|
|
11
|
+
|
|
12
|
+
const DEFAULT_VOICE_VOLUME_LEVEL: VoiceVolumeLevel = 5;
|
|
13
|
+
|
|
14
|
+
export function volumeLevelToGain(level: VoiceVolumeLevel): number {
|
|
15
|
+
return level * 0.2;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readVoiceVolumeLevel(): Promise<VoiceVolumeLevel> {
|
|
19
|
+
try {
|
|
20
|
+
const raw = await AsyncStorage.getItem(LS_VOICE_VOLUME);
|
|
21
|
+
if (!raw) return DEFAULT_VOICE_VOLUME_LEVEL;
|
|
22
|
+
const n = Number.parseInt(raw, 10);
|
|
23
|
+
if (n >= 1 && n <= 5) return n as VoiceVolumeLevel;
|
|
24
|
+
} catch {
|
|
25
|
+
/* ignore */
|
|
26
|
+
}
|
|
27
|
+
return DEFAULT_VOICE_VOLUME_LEVEL;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function writeVoiceVolumeLevel(level: VoiceVolumeLevel): Promise<void> {
|
|
31
|
+
try {
|
|
32
|
+
await AsyncStorage.setItem(LS_VOICE_VOLUME, String(level));
|
|
33
|
+
} catch {
|
|
34
|
+
/* ignore */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type TtsVoicePref = { voiceKey: string } | { disabled: true };
|
|
39
|
+
|
|
40
|
+
export async function readTtsVoicePref(): Promise<TtsVoicePref | null> {
|
|
41
|
+
try {
|
|
42
|
+
const raw = await AsyncStorage.getItem(LS_TTS_VOICE_PREF);
|
|
43
|
+
if (!raw) return null;
|
|
44
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
45
|
+
if (
|
|
46
|
+
parsed &&
|
|
47
|
+
typeof parsed === 'object' &&
|
|
48
|
+
'disabled' in parsed &&
|
|
49
|
+
(parsed as { disabled: unknown }).disabled === true
|
|
50
|
+
) {
|
|
51
|
+
return { disabled: true };
|
|
52
|
+
}
|
|
53
|
+
if (
|
|
54
|
+
parsed &&
|
|
55
|
+
typeof parsed === 'object' &&
|
|
56
|
+
'voiceKey' in parsed &&
|
|
57
|
+
typeof (parsed as { voiceKey: unknown }).voiceKey === 'string'
|
|
58
|
+
) {
|
|
59
|
+
const voiceKey = (parsed as { voiceKey: string }).voiceKey.trim();
|
|
60
|
+
if (voiceKey) return { voiceKey };
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
/* ignore */
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function writeTtsVoicePref(pref: TtsVoicePref): Promise<void> {
|
|
69
|
+
try {
|
|
70
|
+
await AsyncStorage.setItem(LS_TTS_VOICE_PREF, JSON.stringify(pref));
|
|
71
|
+
} catch {
|
|
72
|
+
/* ignore */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function defaultVoiceKey(voices: NavigatorTtsVoices): string | null {
|
|
77
|
+
const keys = Object.keys(voices);
|
|
78
|
+
return keys.length > 0 ? keys[0]! : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function resolveVoiceKey(voices: NavigatorTtsVoices, pref: TtsVoicePref | null): string | null {
|
|
82
|
+
if (pref && 'voiceKey' in pref && pref.voiceKey in voices) {
|
|
83
|
+
return pref.voiceKey;
|
|
84
|
+
}
|
|
85
|
+
return defaultVoiceKey(voices);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Нативное владение TTS навигатора: выбор голоса/громкости, персист,
|
|
90
|
+
* кэш URL по (voiceKey, text). Веб шлёт готовый текст фразы.
|
|
91
|
+
*/
|
|
92
|
+
export type NavigatorTtsController = {
|
|
93
|
+
voiceEntries: [string, string][];
|
|
94
|
+
selectedVoiceKey: string | null;
|
|
95
|
+
voiceEnabled: boolean;
|
|
96
|
+
volumeLevel: VoiceVolumeLevel;
|
|
97
|
+
synthesize: (text: string) => Promise<string | null>;
|
|
98
|
+
selectVoice: (voiceKey: string) => Promise<void>;
|
|
99
|
+
selectVolume: (level: VoiceVolumeLevel) => void;
|
|
100
|
+
disableVoice: () => Promise<void>;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export function useNavigatorTts(
|
|
104
|
+
ttsVoices: NavigatorTtsVoices | undefined,
|
|
105
|
+
ttsHandler: NavigatorTtsHandler | undefined
|
|
106
|
+
): NavigatorTtsController {
|
|
107
|
+
const [selectedVoiceKey, setSelectedVoiceKey] = useState<string | null>(null);
|
|
108
|
+
const [voiceEnabled, setVoiceEnabled] = useState(true);
|
|
109
|
+
const [volumeLevel, setVolumeLevel] = useState<VoiceVolumeLevel>(5);
|
|
110
|
+
const urlCacheRef = useRef(new Map<string, string>());
|
|
111
|
+
|
|
112
|
+
const voiceEntries = Object.entries(ttsVoices ?? {});
|
|
113
|
+
const ttsActive = voiceEntries.length > 0 && typeof ttsHandler === 'function';
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
let cancelled = false;
|
|
117
|
+
void readVoiceVolumeLevel().then((lvl) => {
|
|
118
|
+
if (!cancelled) setVolumeLevel(lvl);
|
|
119
|
+
});
|
|
120
|
+
if (!ttsActive) {
|
|
121
|
+
setSelectedVoiceKey(null);
|
|
122
|
+
setVoiceEnabled(false);
|
|
123
|
+
return () => {
|
|
124
|
+
cancelled = true;
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
void readTtsVoicePref().then((pref) => {
|
|
128
|
+
if (cancelled) return;
|
|
129
|
+
if (pref && 'disabled' in pref) {
|
|
130
|
+
setVoiceEnabled(false);
|
|
131
|
+
setSelectedVoiceKey(resolveVoiceKey(ttsVoices!, pref));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
setVoiceEnabled(true);
|
|
135
|
+
setSelectedVoiceKey(resolveVoiceKey(ttsVoices!, pref));
|
|
136
|
+
});
|
|
137
|
+
return () => {
|
|
138
|
+
cancelled = true;
|
|
139
|
+
};
|
|
140
|
+
}, [ttsActive, ttsVoices]);
|
|
141
|
+
|
|
142
|
+
const selectVoice = useCallback(
|
|
143
|
+
async (voiceKey: string) => {
|
|
144
|
+
if (!ttsVoices || !(voiceKey in ttsVoices)) return;
|
|
145
|
+
setSelectedVoiceKey(voiceKey);
|
|
146
|
+
setVoiceEnabled(true);
|
|
147
|
+
await writeTtsVoicePref({ voiceKey });
|
|
148
|
+
},
|
|
149
|
+
[ttsVoices]
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const selectVolume = useCallback((level: VoiceVolumeLevel) => {
|
|
153
|
+
setVolumeLevel(level);
|
|
154
|
+
void writeVoiceVolumeLevel(level);
|
|
155
|
+
}, []);
|
|
156
|
+
|
|
157
|
+
const disableVoice = useCallback(async () => {
|
|
158
|
+
await writeTtsVoicePref({ disabled: true });
|
|
159
|
+
setVoiceEnabled(false);
|
|
160
|
+
}, []);
|
|
161
|
+
|
|
162
|
+
const synthesize = useCallback(
|
|
163
|
+
async (text: string): Promise<string | null> => {
|
|
164
|
+
if (!ttsHandler || !voiceEnabled || !selectedVoiceKey) return null;
|
|
165
|
+
const trimmed = text.trim();
|
|
166
|
+
if (!trimmed) return null;
|
|
167
|
+
const cacheKey = `${selectedVoiceKey}\0${trimmed}`;
|
|
168
|
+
const cached = urlCacheRef.current.get(cacheKey);
|
|
169
|
+
if (cached) return cached;
|
|
170
|
+
try {
|
|
171
|
+
const url = await ttsHandler(trimmed, selectedVoiceKey);
|
|
172
|
+
if (typeof url === 'string' && url.trim()) {
|
|
173
|
+
const u = url.trim();
|
|
174
|
+
urlCacheRef.current.set(cacheKey, u);
|
|
175
|
+
return u;
|
|
176
|
+
}
|
|
177
|
+
} catch (err) {
|
|
178
|
+
console.warn('[maplite] navigator TTS failed', err);
|
|
179
|
+
}
|
|
180
|
+
return null;
|
|
181
|
+
},
|
|
182
|
+
[ttsHandler, voiceEnabled, selectedVoiceKey]
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
voiceEntries: ttsActive ? voiceEntries : [],
|
|
187
|
+
selectedVoiceKey: ttsActive ? selectedVoiceKey : null,
|
|
188
|
+
voiceEnabled: ttsActive && voiceEnabled,
|
|
189
|
+
volumeLevel,
|
|
190
|
+
synthesize,
|
|
191
|
+
selectVoice,
|
|
192
|
+
selectVolume,
|
|
193
|
+
disableVoice,
|
|
194
|
+
};
|
|
195
|
+
}
|