react-native-maplibre-lite 0.2.0 → 0.2.1
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 +92 -6
- package/lib/module/components/MapView.js +116 -14
- package/lib/module/components/MapView.js.map +1 -1
- package/lib/module/components/NavigatorHud.js +152 -0
- package/lib/module/components/NavigatorHud.js.map +1 -0
- package/lib/module/components/NavigatorRecenterButton.js +48 -0
- package/lib/module/components/NavigatorRecenterButton.js.map +1 -0
- package/lib/module/components/NavigatorVoiceControl.js +173 -0
- package/lib/module/components/NavigatorVoiceControl.js.map +1 -0
- package/lib/module/components/navigatorChromeTheme.js +98 -0
- package/lib/module/components/navigatorChromeTheme.js.map +1 -0
- package/lib/module/components/navigatorManeuverIcon.js +210 -0
- package/lib/module/components/navigatorManeuverIcon.js.map +1 -0
- package/lib/module/components/navigatorVoiceCatalog.js +225 -0
- package/lib/module/components/navigatorVoiceCatalog.js.map +1 -0
- package/lib/module/components/navigatorVoiceKeys.js +14 -0
- package/lib/module/components/navigatorVoiceKeys.js.map +1 -0
- package/lib/module/components/navigatorVoicePlayer.js +100 -0
- package/lib/module/components/navigatorVoicePlayer.js.map +1 -0
- package/lib/module/components/navigatorVoiceStrings.js +31 -0
- package/lib/module/components/navigatorVoiceStrings.js.map +1 -0
- package/lib/module/components/types.js +22 -0
- package/lib/module/components/types.js.map +1 -1
- package/lib/module/components/useNavigatorVoice.js +78 -0
- package/lib/module/components/useNavigatorVoice.js.map +1 -0
- package/lib/module/components/utils.js +26 -0
- package/lib/module/components/utils.js.map +1 -1
- 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 +14 -1
- package/lib/typescript/src/components/MapView.d.ts.map +1 -1
- package/lib/typescript/src/components/NavigatorHud.d.ts +13 -0
- package/lib/typescript/src/components/NavigatorHud.d.ts.map +1 -0
- package/lib/typescript/src/components/NavigatorRecenterButton.d.ts +11 -0
- package/lib/typescript/src/components/NavigatorRecenterButton.d.ts.map +1 -0
- package/lib/typescript/src/components/NavigatorVoiceControl.d.ts +20 -0
- package/lib/typescript/src/components/NavigatorVoiceControl.d.ts.map +1 -0
- package/lib/typescript/src/components/navigatorChromeTheme.d.ts +19 -0
- package/lib/typescript/src/components/navigatorChromeTheme.d.ts.map +1 -0
- package/lib/typescript/src/components/navigatorManeuverIcon.d.ts +20 -0
- package/lib/typescript/src/components/navigatorManeuverIcon.d.ts.map +1 -0
- package/lib/typescript/src/components/navigatorVoiceCatalog.d.ts +50 -0
- package/lib/typescript/src/components/navigatorVoiceCatalog.d.ts.map +1 -0
- package/lib/typescript/src/components/navigatorVoiceKeys.d.ts +10 -0
- package/lib/typescript/src/components/navigatorVoiceKeys.d.ts.map +1 -0
- package/lib/typescript/src/components/navigatorVoicePlayer.d.ts +15 -0
- package/lib/typescript/src/components/navigatorVoicePlayer.d.ts.map +1 -0
- package/lib/typescript/src/components/navigatorVoiceStrings.d.ts +19 -0
- package/lib/typescript/src/components/navigatorVoiceStrings.d.ts.map +1 -0
- package/lib/typescript/src/components/types.d.ts +83 -17
- package/lib/typescript/src/components/types.d.ts.map +1 -1
- package/lib/typescript/src/components/useNavigatorVoice.d.ts +20 -0
- package/lib/typescript/src/components/useNavigatorVoice.d.ts.map +1 -0
- package/lib/typescript/src/components/utils.d.ts +9 -0
- package/lib/typescript/src/components/utils.d.ts.map +1 -1
- 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 +16 -7
- package/resources/README.md +62 -0
- package/resources/map.html +797 -0
- package/src/components/MapView.tsx +154 -8
- package/src/components/NavigatorHud.tsx +166 -0
- package/src/components/NavigatorRecenterButton.tsx +45 -0
- package/src/components/NavigatorVoiceControl.tsx +198 -0
- package/src/components/navigatorChromeTheme.ts +118 -0
- package/src/components/navigatorManeuverIcon.tsx +177 -0
- package/src/components/navigatorVoiceCatalog.ts +275 -0
- package/src/components/navigatorVoiceKeys.ts +132 -0
- package/src/components/navigatorVoicePlayer.tsx +126 -0
- package/src/components/navigatorVoiceStrings.ts +42 -0
- package/src/components/types.ts +87 -18
- package/src/components/useNavigatorVoice.ts +96 -0
- package/src/components/utils.ts +28 -0
- package/src/components/webMapBuild.ts +1 -1
- package/src/index.tsx +8 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
2
|
+
|
|
3
|
+
import { isKnownPhraseKey, type VoicePhraseKey } from './navigatorVoiceKeys';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Каталог/манифест голосов навигатора и персист настроек. Порт
|
|
7
|
+
* `webProject/src/map/navigationVoiceCatalog.ts` на нативную часть:
|
|
8
|
+
* localStorage → AsyncStorage, плюс маппинг ключей фраз в URL клипов.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type VoiceCatalogEntry = {
|
|
12
|
+
dir: string;
|
|
13
|
+
name: string;
|
|
14
|
+
locale: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/** Per-voice manifest from S3: phrase key → variant ids. */
|
|
18
|
+
export type VoiceManifest = Record<string, string[]>;
|
|
19
|
+
|
|
20
|
+
export type VoicePref = { dir: string } | { disabled: true };
|
|
21
|
+
|
|
22
|
+
export type VoiceRuntime = {
|
|
23
|
+
catalogUrl: string;
|
|
24
|
+
catalog: VoiceCatalogEntry[];
|
|
25
|
+
selectedDir: string;
|
|
26
|
+
clipBaseUrl: string;
|
|
27
|
+
manifest: VoiceManifest | null;
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const LS_VOICE_PREF = 'maplite-navigator-voice-pref';
|
|
32
|
+
const LS_VOICE_VOLUME = 'maplite-navigator-voice-volume';
|
|
33
|
+
|
|
34
|
+
export type VoiceVolumeLevel = 1 | 2 | 3 | 4 | 5;
|
|
35
|
+
|
|
36
|
+
const DEFAULT_VOICE_VOLUME_LEVEL: VoiceVolumeLevel = 5;
|
|
37
|
+
|
|
38
|
+
export function volumeLevelToGain(level: VoiceVolumeLevel): number {
|
|
39
|
+
return level * 0.2;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function readVoiceVolumeLevel(): Promise<VoiceVolumeLevel> {
|
|
43
|
+
try {
|
|
44
|
+
const raw = await AsyncStorage.getItem(LS_VOICE_VOLUME);
|
|
45
|
+
if (!raw) return DEFAULT_VOICE_VOLUME_LEVEL;
|
|
46
|
+
const n = Number.parseInt(raw, 10);
|
|
47
|
+
if (n >= 1 && n <= 5) return n as VoiceVolumeLevel;
|
|
48
|
+
} catch {
|
|
49
|
+
/* ignore */
|
|
50
|
+
}
|
|
51
|
+
return DEFAULT_VOICE_VOLUME_LEVEL;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function writeVoiceVolumeLevel(level: VoiceVolumeLevel): Promise<void> {
|
|
55
|
+
try {
|
|
56
|
+
await AsyncStorage.setItem(LS_VOICE_VOLUME, String(level));
|
|
57
|
+
} catch {
|
|
58
|
+
/* ignore */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function readVoicePref(): Promise<VoicePref | null> {
|
|
63
|
+
try {
|
|
64
|
+
const raw = await AsyncStorage.getItem(LS_VOICE_PREF);
|
|
65
|
+
if (!raw) return null;
|
|
66
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
67
|
+
if (
|
|
68
|
+
parsed &&
|
|
69
|
+
typeof parsed === 'object' &&
|
|
70
|
+
'disabled' in parsed &&
|
|
71
|
+
(parsed as { disabled: unknown }).disabled === true
|
|
72
|
+
) {
|
|
73
|
+
return { disabled: true };
|
|
74
|
+
}
|
|
75
|
+
if (
|
|
76
|
+
parsed &&
|
|
77
|
+
typeof parsed === 'object' &&
|
|
78
|
+
'dir' in parsed &&
|
|
79
|
+
typeof (parsed as { dir: unknown }).dir === 'string'
|
|
80
|
+
) {
|
|
81
|
+
const dir = (parsed as { dir: string }).dir.trim();
|
|
82
|
+
if (dir) return { dir };
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
/* ignore */
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function writeVoicePref(pref: VoicePref): Promise<void> {
|
|
91
|
+
try {
|
|
92
|
+
await AsyncStorage.setItem(LS_VOICE_PREF, JSON.stringify(pref));
|
|
93
|
+
} catch {
|
|
94
|
+
/* ignore */
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function resolveVoiceCatalogUrl(url: string): string | null {
|
|
99
|
+
const t = url.trim();
|
|
100
|
+
if (!t) return null;
|
|
101
|
+
try {
|
|
102
|
+
// eslint-disable-next-line no-new
|
|
103
|
+
new URL(t);
|
|
104
|
+
return t;
|
|
105
|
+
} catch {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Base URL for a voice directory: `.../voices/{dir}`. */
|
|
111
|
+
export function voiceDirBaseUrl(catalogUrl: string, dir: string): string {
|
|
112
|
+
const u = new URL(catalogUrl);
|
|
113
|
+
const parts = u.pathname.split('/').filter(Boolean);
|
|
114
|
+
if (parts.length > 0 && parts[parts.length - 1]!.toLowerCase().endsWith('.json')) {
|
|
115
|
+
parts.pop();
|
|
116
|
+
}
|
|
117
|
+
parts.push(dir);
|
|
118
|
+
u.pathname = `/${parts.join('/')}`;
|
|
119
|
+
return u.toString().replace(/\/+$/, '');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function voiceManifestUrl(baseUrl: string): string {
|
|
123
|
+
return `${baseUrl.replace(/\/+$/, '')}/data.json`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function fetchVoiceCatalog(catalogUrl: string): Promise<VoiceCatalogEntry[]> {
|
|
127
|
+
const res = await fetch(catalogUrl);
|
|
128
|
+
if (!res.ok) {
|
|
129
|
+
throw new Error(`voice catalog fetch failed: ${res.status}`);
|
|
130
|
+
}
|
|
131
|
+
const data = (await res.json()) as unknown;
|
|
132
|
+
if (!Array.isArray(data)) {
|
|
133
|
+
throw new Error('voice catalog: expected JSON array');
|
|
134
|
+
}
|
|
135
|
+
const entries: VoiceCatalogEntry[] = [];
|
|
136
|
+
for (const item of data) {
|
|
137
|
+
if (
|
|
138
|
+
item &&
|
|
139
|
+
typeof item === 'object' &&
|
|
140
|
+
typeof (item as VoiceCatalogEntry).dir === 'string' &&
|
|
141
|
+
typeof (item as VoiceCatalogEntry).name === 'string'
|
|
142
|
+
) {
|
|
143
|
+
const e = item as VoiceCatalogEntry;
|
|
144
|
+
entries.push({
|
|
145
|
+
dir: e.dir.trim(),
|
|
146
|
+
name: e.name,
|
|
147
|
+
locale: typeof e.locale === 'string' ? e.locale : 'RU',
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (entries.length === 0) {
|
|
152
|
+
throw new Error('voice catalog: empty');
|
|
153
|
+
}
|
|
154
|
+
return entries;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export async function fetchVoiceManifest(clipBaseUrl: string): Promise<VoiceManifest> {
|
|
158
|
+
const res = await fetch(voiceManifestUrl(clipBaseUrl));
|
|
159
|
+
if (!res.ok) {
|
|
160
|
+
throw new Error(`voice manifest fetch failed: ${res.status}`);
|
|
161
|
+
}
|
|
162
|
+
const data = (await res.json()) as unknown;
|
|
163
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
164
|
+
throw new Error('voice manifest: expected JSON object');
|
|
165
|
+
}
|
|
166
|
+
const manifest: VoiceManifest = {};
|
|
167
|
+
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
|
|
168
|
+
if (Array.isArray(value) && value.every((v) => typeof v === 'string')) {
|
|
169
|
+
manifest[key] = value as string[];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return manifest;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function resolveSelectedDir(catalog: VoiceCatalogEntry[], pref: VoicePref | null): string {
|
|
176
|
+
if (pref && 'dir' in pref) {
|
|
177
|
+
const found = catalog.find((e) => e.dir === pref.dir);
|
|
178
|
+
if (found) return found.dir;
|
|
179
|
+
}
|
|
180
|
+
return catalog[0]!.dir;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Loads catalog + user preference + optional per-voice manifest.
|
|
185
|
+
* When pref is `{ disabled: true }`, returns `enabled: false` and `manifest: null`.
|
|
186
|
+
*/
|
|
187
|
+
export async function loadVoiceRuntime(catalogUrl: string): Promise<VoiceRuntime | null> {
|
|
188
|
+
const resolved = resolveVoiceCatalogUrl(catalogUrl);
|
|
189
|
+
if (!resolved) return null;
|
|
190
|
+
|
|
191
|
+
const catalog = await fetchVoiceCatalog(resolved);
|
|
192
|
+
const pref = await readVoicePref();
|
|
193
|
+
const selectedDir = resolveSelectedDir(catalog, pref);
|
|
194
|
+
const clipBaseUrl = voiceDirBaseUrl(resolved, selectedDir);
|
|
195
|
+
|
|
196
|
+
if (pref != null && 'disabled' in pref && pref.disabled === true) {
|
|
197
|
+
return {
|
|
198
|
+
catalogUrl: resolved,
|
|
199
|
+
catalog,
|
|
200
|
+
selectedDir,
|
|
201
|
+
clipBaseUrl,
|
|
202
|
+
manifest: null,
|
|
203
|
+
enabled: false,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const manifest = await fetchVoiceManifest(clipBaseUrl);
|
|
208
|
+
return {
|
|
209
|
+
catalogUrl: resolved,
|
|
210
|
+
catalog,
|
|
211
|
+
selectedDir,
|
|
212
|
+
clipBaseUrl,
|
|
213
|
+
manifest,
|
|
214
|
+
enabled: true,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Reload manifest after user picks another voice (same catalog URL). */
|
|
219
|
+
export async function reloadVoiceForDir(
|
|
220
|
+
catalogUrl: string,
|
|
221
|
+
catalog: VoiceCatalogEntry[],
|
|
222
|
+
dir: string
|
|
223
|
+
): Promise<Pick<VoiceRuntime, 'selectedDir' | 'clipBaseUrl' | 'manifest' | 'enabled'>> {
|
|
224
|
+
const entry = catalog.find((e) => e.dir === dir);
|
|
225
|
+
if (!entry) {
|
|
226
|
+
throw new Error(`voice dir not in catalog: ${dir}`);
|
|
227
|
+
}
|
|
228
|
+
const clipBaseUrl = voiceDirBaseUrl(catalogUrl, entry.dir);
|
|
229
|
+
const manifest = await fetchVoiceManifest(clipBaseUrl);
|
|
230
|
+
await writeVoicePref({ dir: entry.dir });
|
|
231
|
+
return {
|
|
232
|
+
selectedDir: entry.dir,
|
|
233
|
+
clipBaseUrl,
|
|
234
|
+
manifest,
|
|
235
|
+
enabled: true,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function pickVariant(manifest: VoiceManifest, key: VoicePhraseKey): string {
|
|
240
|
+
const vars = manifest[key];
|
|
241
|
+
if (!vars?.length) return 'var1';
|
|
242
|
+
return vars[Math.floor(Math.random() * vars.length)] ?? 'var1';
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** clipId для S3: `{key}_{variant}`. */
|
|
246
|
+
function phraseKeyToClipId(manifest: VoiceManifest, key: VoicePhraseKey, variant: string): string {
|
|
247
|
+
return `${key}_${variant}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Преобразует список ключей фраз (из веб-части) в список URL клипов
|
|
252
|
+
* `{clipBaseUrl}/{key}_{variant}.mp3`. Неизвестные / отсутствующие в
|
|
253
|
+
* манифесте ключи пропускаются.
|
|
254
|
+
*/
|
|
255
|
+
export function keysToClipUrls(
|
|
256
|
+
manifest: VoiceManifest,
|
|
257
|
+
clipBaseUrl: string,
|
|
258
|
+
keys: string[]
|
|
259
|
+
): string[] {
|
|
260
|
+
const base = clipBaseUrl.replace(/\/+$/, '');
|
|
261
|
+
const variants = new Map<VoicePhraseKey, string>();
|
|
262
|
+
const urls: string[] = [];
|
|
263
|
+
for (const raw of keys) {
|
|
264
|
+
if (!isKnownPhraseKey(raw)) continue;
|
|
265
|
+
const key = raw;
|
|
266
|
+
if (!(key in manifest)) continue;
|
|
267
|
+
let variant = variants.get(key);
|
|
268
|
+
if (!variant) {
|
|
269
|
+
variant = pickVariant(manifest, key);
|
|
270
|
+
variants.set(key, variant);
|
|
271
|
+
}
|
|
272
|
+
urls.push(`${base}/${phraseKeyToClipId(manifest, key, variant)}.mp3`);
|
|
273
|
+
}
|
|
274
|
+
return urls;
|
|
275
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Фиксированные ключи клипов навигатора (схема проекта). Зеркало
|
|
3
|
+
* `webProject/src/map/navigationVoiceKeys.ts` — веб-часть присылает эти
|
|
4
|
+
* ключи в событии `navigatorVoice`, нативная часть мапит их в аудио.
|
|
5
|
+
* Варианты (`var1`, …) и наличие записей — только из S3 voice manifest.
|
|
6
|
+
*/
|
|
7
|
+
export const VOICE_PHRASE_KEYS = [
|
|
8
|
+
'maneuver_sharp_left',
|
|
9
|
+
'maneuver_turn_left',
|
|
10
|
+
'maneuver_slight_left',
|
|
11
|
+
'maneuver_straight',
|
|
12
|
+
'maneuver_slight_right',
|
|
13
|
+
'maneuver_turn_right',
|
|
14
|
+
'maneuver_sharp_right',
|
|
15
|
+
'maneuver_arrival',
|
|
16
|
+
'maneuver_waypoint',
|
|
17
|
+
'maneuver_enter_roundabout',
|
|
18
|
+
'maneuver_leave_roundabout',
|
|
19
|
+
'maneuver_keep_left',
|
|
20
|
+
'maneuver_keep_right',
|
|
21
|
+
'maneuver_uturn',
|
|
22
|
+
'maneuver_uturn_left',
|
|
23
|
+
'maneuver_uturn_right',
|
|
24
|
+
'maneuver_default',
|
|
25
|
+
'exit_1',
|
|
26
|
+
'exit_2',
|
|
27
|
+
'exit_3',
|
|
28
|
+
'exit_4',
|
|
29
|
+
'exit_5',
|
|
30
|
+
'exit_6',
|
|
31
|
+
'exit_7',
|
|
32
|
+
'exit_8',
|
|
33
|
+
'exit_9',
|
|
34
|
+
'exit_10',
|
|
35
|
+
'exit_11',
|
|
36
|
+
'exit_12',
|
|
37
|
+
'roundabout_take_exit',
|
|
38
|
+
'prefix_via',
|
|
39
|
+
'connector_and',
|
|
40
|
+
'unit_meter',
|
|
41
|
+
'unit_meters_2_4',
|
|
42
|
+
'unit_meters_5_plus',
|
|
43
|
+
'unit_kilometer',
|
|
44
|
+
'unit_kilometers_2_4',
|
|
45
|
+
'unit_kilometers_5_plus',
|
|
46
|
+
'route_started',
|
|
47
|
+
'route_recalculated',
|
|
48
|
+
'arrived_title',
|
|
49
|
+
'arrival_at_destination',
|
|
50
|
+
'waypoint_reached',
|
|
51
|
+
'off_route',
|
|
52
|
+
'num_1',
|
|
53
|
+
'num_2',
|
|
54
|
+
'num_3',
|
|
55
|
+
'num_4',
|
|
56
|
+
'num_5',
|
|
57
|
+
'num_6',
|
|
58
|
+
'num_7',
|
|
59
|
+
'num_8',
|
|
60
|
+
'num_9',
|
|
61
|
+
'num_10',
|
|
62
|
+
'num_11',
|
|
63
|
+
'num_12',
|
|
64
|
+
'num_13',
|
|
65
|
+
'num_14',
|
|
66
|
+
'num_15',
|
|
67
|
+
'num_16',
|
|
68
|
+
'num_17',
|
|
69
|
+
'num_18',
|
|
70
|
+
'num_19',
|
|
71
|
+
'num_20',
|
|
72
|
+
'num_30',
|
|
73
|
+
'num_40',
|
|
74
|
+
'num_50',
|
|
75
|
+
'num_60',
|
|
76
|
+
'num_70',
|
|
77
|
+
'num_80',
|
|
78
|
+
'num_90',
|
|
79
|
+
'num_100',
|
|
80
|
+
'num_200',
|
|
81
|
+
'num_300',
|
|
82
|
+
'num_400',
|
|
83
|
+
'num_500',
|
|
84
|
+
'num_600',
|
|
85
|
+
'num_700',
|
|
86
|
+
'num_800',
|
|
87
|
+
'num_900',
|
|
88
|
+
'num_km_1',
|
|
89
|
+
'num_km_2',
|
|
90
|
+
'num_km_3',
|
|
91
|
+
'num_km_4',
|
|
92
|
+
'num_km_5',
|
|
93
|
+
'num_km_6',
|
|
94
|
+
'num_km_7',
|
|
95
|
+
'num_km_8',
|
|
96
|
+
'num_km_9',
|
|
97
|
+
'num_km_10',
|
|
98
|
+
'num_km_11',
|
|
99
|
+
'num_km_12',
|
|
100
|
+
'num_km_13',
|
|
101
|
+
'num_km_14',
|
|
102
|
+
'num_km_15',
|
|
103
|
+
'num_km_16',
|
|
104
|
+
'num_km_17',
|
|
105
|
+
'num_km_18',
|
|
106
|
+
'num_km_19',
|
|
107
|
+
'num_km_20',
|
|
108
|
+
'num_km_30',
|
|
109
|
+
'num_km_40',
|
|
110
|
+
'num_km_50',
|
|
111
|
+
'num_km_60',
|
|
112
|
+
'num_km_70',
|
|
113
|
+
'num_km_80',
|
|
114
|
+
'num_km_90',
|
|
115
|
+
'num_km_100',
|
|
116
|
+
'num_km_200',
|
|
117
|
+
'num_km_300',
|
|
118
|
+
'num_km_400',
|
|
119
|
+
'num_km_500',
|
|
120
|
+
'num_km_600',
|
|
121
|
+
'num_km_700',
|
|
122
|
+
'num_km_800',
|
|
123
|
+
'num_km_900',
|
|
124
|
+
] as const;
|
|
125
|
+
|
|
126
|
+
export type VoicePhraseKey = (typeof VOICE_PHRASE_KEYS)[number];
|
|
127
|
+
|
|
128
|
+
const VOICE_PHRASE_KEY_SET = new Set<string>(VOICE_PHRASE_KEYS);
|
|
129
|
+
|
|
130
|
+
export function isKnownPhraseKey(key: string): key is VoicePhraseKey {
|
|
131
|
+
return VOICE_PHRASE_KEY_SET.has(key);
|
|
132
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { forwardRef, useImperativeHandle, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import Video from 'react-native-video';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Последовательный проигрыватель клипов озвучки навигатора. Порт логики
|
|
7
|
+
* очереди из `webProject/src/map/navigationVoicePlayer.ts` на `react-native-video`:
|
|
8
|
+
* веб присылает список ключей фраз, нативная часть собирает из них URL и
|
|
9
|
+
* проигрывает их по очереди с короткой паузой между клипами.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Пауза между клипами (как в веб-плеере). */
|
|
13
|
+
const CLIP_PAUSE_MS = 300;
|
|
14
|
+
|
|
15
|
+
export type NavigatorVoicePlayerRef = {
|
|
16
|
+
/** Проиграть очередь URL подряд (прерывает текущую очередь). */
|
|
17
|
+
playUrls: (urls: string[]) => void;
|
|
18
|
+
/** Остановить воспроизведение и очистить очередь. */
|
|
19
|
+
stop: () => void;
|
|
20
|
+
/** Установить громкость (0..1). */
|
|
21
|
+
setVolume: (gain: number) => void;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type NavigatorVoicePlayerProps = {
|
|
25
|
+
/** Начальная громкость (0..1); далее управляется через ref. */
|
|
26
|
+
initialVolume?: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const NavigatorVoicePlayer = forwardRef<
|
|
30
|
+
NavigatorVoicePlayerRef,
|
|
31
|
+
NavigatorVoicePlayerProps
|
|
32
|
+
>((props, ref) => {
|
|
33
|
+
const [current, setCurrent] = useState<{ uri: string; token: number } | null>(null);
|
|
34
|
+
const [volume, setVolume] = useState(
|
|
35
|
+
typeof props.initialVolume === 'number'
|
|
36
|
+
? Math.min(1, Math.max(0, props.initialVolume))
|
|
37
|
+
: 1
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const queueRef = useRef<string[]>([]);
|
|
41
|
+
const indexRef = useRef(0);
|
|
42
|
+
/** Поколение очереди: рост инвалидирует «хвосты» предыдущих очередей. */
|
|
43
|
+
const genRef = useRef(0);
|
|
44
|
+
/** Меняем токен, чтобы перерисовать `<Video>` даже при том же URL подряд. */
|
|
45
|
+
const tokenRef = useRef(0);
|
|
46
|
+
const pauseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
47
|
+
|
|
48
|
+
const clearPause = () => {
|
|
49
|
+
if (pauseTimerRef.current != null) {
|
|
50
|
+
clearTimeout(pauseTimerRef.current);
|
|
51
|
+
pauseTimerRef.current = null;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const playIndex = () => {
|
|
56
|
+
const uri = queueRef.current[indexRef.current];
|
|
57
|
+
if (uri == null) {
|
|
58
|
+
setCurrent(null);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
tokenRef.current += 1;
|
|
62
|
+
setCurrent({ uri, token: tokenRef.current });
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const advance = (gen: number) => {
|
|
66
|
+
if (gen !== genRef.current) return;
|
|
67
|
+
indexRef.current += 1;
|
|
68
|
+
if (indexRef.current >= queueRef.current.length) {
|
|
69
|
+
setCurrent(null);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
clearPause();
|
|
73
|
+
pauseTimerRef.current = setTimeout(() => {
|
|
74
|
+
pauseTimerRef.current = null;
|
|
75
|
+
if (gen === genRef.current) playIndex();
|
|
76
|
+
}, CLIP_PAUSE_MS);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
useImperativeHandle(
|
|
80
|
+
ref,
|
|
81
|
+
() => ({
|
|
82
|
+
playUrls: (urls: string[]) => {
|
|
83
|
+
clearPause();
|
|
84
|
+
genRef.current += 1;
|
|
85
|
+
queueRef.current = urls;
|
|
86
|
+
indexRef.current = 0;
|
|
87
|
+
if (urls.length === 0) {
|
|
88
|
+
setCurrent(null);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
playIndex();
|
|
92
|
+
},
|
|
93
|
+
stop: () => {
|
|
94
|
+
clearPause();
|
|
95
|
+
genRef.current += 1;
|
|
96
|
+
queueRef.current = [];
|
|
97
|
+
indexRef.current = 0;
|
|
98
|
+
setCurrent(null);
|
|
99
|
+
},
|
|
100
|
+
setVolume: (gain: number) => {
|
|
101
|
+
setVolume(Math.min(1, Math.max(0, gain)));
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
[]
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
if (!current) return null;
|
|
108
|
+
|
|
109
|
+
const gen = genRef.current;
|
|
110
|
+
return (
|
|
111
|
+
<Video
|
|
112
|
+
key={current.token}
|
|
113
|
+
source={{ uri: current.uri }}
|
|
114
|
+
paused={false}
|
|
115
|
+
volume={volume}
|
|
116
|
+
repeat={false}
|
|
117
|
+
ignoreSilentSwitch="ignore"
|
|
118
|
+
playInBackground
|
|
119
|
+
onEnd={() => advance(gen)}
|
|
120
|
+
onError={() => advance(gen)}
|
|
121
|
+
style={{ width: 0, height: 0 }}
|
|
122
|
+
/>
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
NavigatorVoicePlayer.displayName = 'NavigatorVoicePlayer';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { NavigatorLang } from './types';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Подписи нативного UI навигатора (FAB-кнопки, меню голоса, подсказка
|
|
5
|
+
* pick-режима). Текст HUD маршрута приходит готовым из веб-части, поэтому
|
|
6
|
+
* здесь только то, что рисуется нативно. Порт voice/recenter-строк из
|
|
7
|
+
* `webProject/src/map/local.ts`.
|
|
8
|
+
*/
|
|
9
|
+
export type NavigatorUiStrings = {
|
|
10
|
+
recenterAria: string;
|
|
11
|
+
voicePickerAria: string;
|
|
12
|
+
voiceDisable: string;
|
|
13
|
+
voiceOff: string;
|
|
14
|
+
voiceVolumeLabel: string;
|
|
15
|
+
voiceVolumeStepAria: (step: number) => string;
|
|
16
|
+
/** Подсказка в HUD, когда активен режim выбора позиции кликом (dev). */
|
|
17
|
+
pickHint: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const ru: NavigatorUiStrings = {
|
|
21
|
+
recenterAria: 'Вернуть фокус на положение',
|
|
22
|
+
voicePickerAria: 'Голос навигатора',
|
|
23
|
+
voiceDisable: 'Отключить озвучку',
|
|
24
|
+
voiceOff: 'Озвучка выкл.',
|
|
25
|
+
voiceVolumeLabel: 'Громкость',
|
|
26
|
+
voiceVolumeStepAria: (step: number) => `Громкость ${step} из 5`,
|
|
27
|
+
pickHint: 'Кликните по карте, чтобы установить текущее положение',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const en: NavigatorUiStrings = {
|
|
31
|
+
recenterAria: 'Recenter on your position',
|
|
32
|
+
voicePickerAria: 'Navigator voice',
|
|
33
|
+
voiceDisable: 'Turn off voice guidance',
|
|
34
|
+
voiceOff: 'Voice off',
|
|
35
|
+
voiceVolumeLabel: 'Volume',
|
|
36
|
+
voiceVolumeStepAria: (step: number) => `Volume ${step} of 5`,
|
|
37
|
+
pickHint: 'Tap the map to set the current position',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export function navigatorUiStrings(lang: NavigatorLang | undefined): NavigatorUiStrings {
|
|
41
|
+
return lang === 'en' ? en : ru;
|
|
42
|
+
}
|