@ethora/chat-component-rn 26.5.11 → 26.5.13
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/CHANGELOG.md +6 -0
- package/README.md +1 -0
- package/lib/commonjs/assets/icons.js +7 -1
- package/lib/commonjs/assets/icons.js.map +1 -1
- package/lib/commonjs/components/MainComponents/ChatRoom.js +58 -22
- package/lib/commonjs/components/MainComponents/ChatRoom.js.map +1 -1
- package/lib/commonjs/components/MainComponents/MediaMessage.js +288 -5
- package/lib/commonjs/components/MainComponents/MediaMessage.js.map +1 -1
- package/lib/commonjs/components/MainComponents/ReduxWrapper.js +24 -15
- package/lib/commonjs/components/MainComponents/ReduxWrapper.js.map +1 -1
- package/lib/commonjs/components/MessageBubble/MessageInteractions.js +23 -3
- package/lib/commonjs/components/MessageBubble/MessageInteractions.js.map +1 -1
- package/lib/commonjs/components/Modals/FilePreviewModal/DocumentViewer.js +5 -44
- package/lib/commonjs/components/Modals/FilePreviewModal/DocumentViewer.js.map +1 -1
- package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js +36 -3
- package/lib/commonjs/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
- package/lib/commonjs/components/Modals/FilePreviewModal/GViewWebView.js +149 -0
- package/lib/commonjs/components/Modals/FilePreviewModal/GViewWebView.js.map +1 -0
- package/lib/commonjs/components/Modals/FilePreviewModal/PdfView.js +4 -17
- package/lib/commonjs/components/Modals/FilePreviewModal/PdfView.js.map +1 -1
- package/lib/commonjs/components/RoomComponents/styled/LastAudioMessage.js +19 -33
- package/lib/commonjs/components/RoomComponents/styled/LastAudioMessage.js.map +1 -1
- package/lib/commonjs/components/styled/AudioMessage.js +643 -66
- package/lib/commonjs/components/styled/AudioMessage.js.map +1 -1
- package/lib/commonjs/components/styled/Button.js +9 -3
- package/lib/commonjs/components/styled/Button.js.map +1 -1
- package/lib/commonjs/components/styled/SendInput.js +239 -40
- package/lib/commonjs/components/styled/SendInput.js.map +1 -1
- package/lib/commonjs/components/styled/StyledInputComponents/MediaComponents.js +31 -18
- package/lib/commonjs/components/styled/StyledInputComponents/MediaComponents.js.map +1 -1
- package/lib/commonjs/components/styled/UnsupportedType.js +47 -5
- package/lib/commonjs/components/styled/UnsupportedType.js.map +1 -1
- package/lib/commonjs/context/xmppProvider.js +68 -2
- package/lib/commonjs/context/xmppProvider.js.map +1 -1
- package/lib/commonjs/helpers/keyboardLayout.js +5 -3
- package/lib/commonjs/helpers/keyboardLayout.js.map +1 -1
- package/lib/commonjs/helpers/mimeToExtension.js +70 -3
- package/lib/commonjs/helpers/mimeToExtension.js.map +1 -1
- package/lib/commonjs/hooks/useEventHandlers.js +1 -1
- package/lib/commonjs/hooks/useEventHandlers.js.map +1 -1
- package/lib/commonjs/hooks/useSendMessage.js +6 -3
- package/lib/commonjs/hooks/useSendMessage.js.map +1 -1
- package/lib/commonjs/networking/xmppClient.js +26 -1
- package/lib/commonjs/networking/xmppClient.js.map +1 -1
- package/lib/commonjs/roomStore/Middleware/unreadMidlleware.js +10 -10
- package/lib/commonjs/roomStore/Middleware/unreadMidlleware.js.map +1 -1
- package/lib/commonjs/roomStore/roomsSlice.js +65 -4
- package/lib/commonjs/roomStore/roomsSlice.js.map +1 -1
- package/lib/module/assets/icons.js +7 -1
- package/lib/module/assets/icons.js.map +1 -1
- package/lib/module/components/MainComponents/ChatRoom.js +59 -23
- package/lib/module/components/MainComponents/ChatRoom.js.map +1 -1
- package/lib/module/components/MainComponents/MediaMessage.js +290 -7
- package/lib/module/components/MainComponents/MediaMessage.js.map +1 -1
- package/lib/module/components/MainComponents/ReduxWrapper.js +24 -15
- package/lib/module/components/MainComponents/ReduxWrapper.js.map +1 -1
- package/lib/module/components/MessageBubble/MessageInteractions.js +24 -4
- package/lib/module/components/MessageBubble/MessageInteractions.js.map +1 -1
- package/lib/module/components/Modals/FilePreviewModal/DocumentViewer.js +5 -45
- package/lib/module/components/Modals/FilePreviewModal/DocumentViewer.js.map +1 -1
- package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js +36 -3
- package/lib/module/components/Modals/FilePreviewModal/FilePreviewModal.js.map +1 -1
- package/lib/module/components/Modals/FilePreviewModal/GViewWebView.js +144 -0
- package/lib/module/components/Modals/FilePreviewModal/GViewWebView.js.map +1 -0
- package/lib/module/components/Modals/FilePreviewModal/PdfView.js +3 -17
- package/lib/module/components/Modals/FilePreviewModal/PdfView.js.map +1 -1
- package/lib/module/components/RoomComponents/styled/LastAudioMessage.js +20 -34
- package/lib/module/components/RoomComponents/styled/LastAudioMessage.js.map +1 -1
- package/lib/module/components/styled/AudioMessage.js +644 -67
- package/lib/module/components/styled/AudioMessage.js.map +1 -1
- package/lib/module/components/styled/Button.js +9 -3
- package/lib/module/components/styled/Button.js.map +1 -1
- package/lib/module/components/styled/SendInput.js +240 -42
- package/lib/module/components/styled/SendInput.js.map +1 -1
- package/lib/module/components/styled/StyledInputComponents/MediaComponents.js +31 -18
- package/lib/module/components/styled/StyledInputComponents/MediaComponents.js.map +1 -1
- package/lib/module/components/styled/UnsupportedType.js +47 -5
- package/lib/module/components/styled/UnsupportedType.js.map +1 -1
- package/lib/module/context/xmppProvider.js +70 -4
- package/lib/module/context/xmppProvider.js.map +1 -1
- package/lib/module/helpers/keyboardLayout.js +4 -2
- package/lib/module/helpers/keyboardLayout.js.map +1 -1
- package/lib/module/helpers/mimeToExtension.js +67 -3
- package/lib/module/helpers/mimeToExtension.js.map +1 -1
- package/lib/module/hooks/useEventHandlers.js +1 -1
- package/lib/module/hooks/useEventHandlers.js.map +1 -1
- package/lib/module/hooks/useSendMessage.js +6 -3
- package/lib/module/hooks/useSendMessage.js.map +1 -1
- package/lib/module/networking/xmppClient.js +26 -1
- package/lib/module/networking/xmppClient.js.map +1 -1
- package/lib/module/roomStore/Middleware/unreadMidlleware.js +10 -10
- package/lib/module/roomStore/Middleware/unreadMidlleware.js.map +1 -1
- package/lib/module/roomStore/roomsSlice.js +63 -3
- package/lib/module/roomStore/roomsSlice.js.map +1 -1
- package/lib/typescript/assets/icons.d.ts.map +1 -1
- package/lib/typescript/components/MainComponents/ChatRoom.d.ts.map +1 -1
- package/lib/typescript/components/MainComponents/MediaMessage.d.ts.map +1 -1
- package/lib/typescript/components/MainComponents/ReduxWrapper.d.ts.map +1 -1
- package/lib/typescript/components/MessageBubble/MessageInteractions.d.ts.map +1 -1
- package/lib/typescript/components/Modals/FilePreviewModal/DocumentViewer.d.ts +0 -7
- package/lib/typescript/components/Modals/FilePreviewModal/DocumentViewer.d.ts.map +1 -1
- package/lib/typescript/components/Modals/FilePreviewModal/FilePreviewModal.d.ts.map +1 -1
- package/lib/typescript/components/Modals/FilePreviewModal/GViewWebView.d.ts +8 -0
- package/lib/typescript/components/Modals/FilePreviewModal/GViewWebView.d.ts.map +1 -0
- package/lib/typescript/components/Modals/FilePreviewModal/PdfView.d.ts.map +1 -1
- package/lib/typescript/components/RoomComponents/styled/LastAudioMessage.d.ts.map +1 -1
- package/lib/typescript/components/styled/AudioMessage.d.ts +8 -2
- package/lib/typescript/components/styled/AudioMessage.d.ts.map +1 -1
- package/lib/typescript/components/styled/Button.d.ts.map +1 -1
- package/lib/typescript/components/styled/SendInput.d.ts.map +1 -1
- package/lib/typescript/components/styled/StyledInputComponents/MediaComponents.d.ts +5 -1
- package/lib/typescript/components/styled/StyledInputComponents/MediaComponents.d.ts.map +1 -1
- package/lib/typescript/components/styled/UnsupportedType.d.ts +5 -0
- package/lib/typescript/components/styled/UnsupportedType.d.ts.map +1 -1
- package/lib/typescript/context/xmppProvider.d.ts.map +1 -1
- package/lib/typescript/helpers/keyboardLayout.d.ts +3 -1
- package/lib/typescript/helpers/keyboardLayout.d.ts.map +1 -1
- package/lib/typescript/helpers/mimeToExtension.d.ts +31 -1
- package/lib/typescript/helpers/mimeToExtension.d.ts.map +1 -1
- package/lib/typescript/hooks/useSendMessage.d.ts.map +1 -1
- package/lib/typescript/networking/xmppClient.d.ts.map +1 -1
- package/lib/typescript/roomStore/Middleware/unreadMidlleware.d.ts.map +1 -1
- package/lib/typescript/roomStore/roomsSlice.d.ts +18 -1
- package/lib/typescript/roomStore/roomsSlice.d.ts.map +1 -1
- package/lib/typescript/types/models/modal.model.d.ts +3 -0
- package/lib/typescript/types/models/modal.model.d.ts.map +1 -1
- package/lib/typescript/types/types.d.ts +15 -0
- package/lib/typescript/types/types.d.ts.map +1 -1
- package/package.json +5 -1
- package/src/assets/icons.tsx +30 -25
- package/src/components/MainComponents/ChatRoom.tsx +61 -23
- package/src/components/MainComponents/MediaMessage.tsx +295 -5
- package/src/components/MainComponents/ReduxWrapper.tsx +23 -9
- package/src/components/MessageBubble/MessageInteractions.tsx +35 -3
- package/src/components/Modals/FilePreviewModal/DocumentViewer.tsx +3 -40
- package/src/components/Modals/FilePreviewModal/FilePreviewModal.tsx +39 -3
- package/src/components/Modals/FilePreviewModal/GViewWebView.tsx +142 -0
- package/src/components/Modals/FilePreviewModal/PdfView.tsx +2 -18
- package/src/components/RoomComponents/styled/LastAudioMessage.tsx +20 -35
- package/src/components/styled/AudioMessage.tsx +724 -59
- package/src/components/styled/Button.tsx +20 -11
- package/src/components/styled/SendInput.tsx +255 -46
- package/src/components/styled/StyledInputComponents/MediaComponents.tsx +29 -18
- package/src/components/styled/UnsupportedType.tsx +60 -6
- package/src/context/xmppProvider.tsx +61 -3
- package/src/helpers/keyboardLayout.ts +5 -1
- package/src/helpers/mimeToExtension.ts +116 -2
- package/src/hooks/useEventHandlers.tsx +1 -1
- package/src/hooks/useSendMessage.tsx +12 -6
- package/src/networking/xmppClient.ts +24 -1
- package/src/roomStore/Middleware/unreadMidlleware.tsx +11 -8
- package/src/roomStore/roomsSlice.ts +66 -2
- package/src/types/models/modal.model.ts +3 -0
- package/src/types/types.ts +32 -0
|
@@ -5,88 +5,731 @@ import {
|
|
|
5
5
|
Text,
|
|
6
6
|
StyleSheet,
|
|
7
7
|
ActivityIndicator,
|
|
8
|
+
Platform,
|
|
8
9
|
} from 'react-native';
|
|
9
10
|
import { Audio, AVPlaybackStatus } from 'expo-av';
|
|
11
|
+
import * as FileSystem from 'expo-file-system/legacy';
|
|
12
|
+
import { WebView, WebViewMessageEvent } from 'react-native-webview';
|
|
13
|
+
import { PauseIcon, PlayIcon } from '../../assets/icons';
|
|
14
|
+
import { useChatSettingState } from '../../hooks/useChatSettingState';
|
|
15
|
+
import { getIosAudioPlaybackCacheExtension } from '../../helpers/mimeToExtension';
|
|
16
|
+
import { pushLog as devPushLog } from '../../utils/devLogger';
|
|
10
17
|
|
|
11
18
|
const formatTime = (millis: number) => {
|
|
12
|
-
const
|
|
19
|
+
const safe = Number.isFinite(millis) && millis > 0 ? millis : 0;
|
|
20
|
+
const totalSec = Math.floor(safe / 1000);
|
|
13
21
|
const min = Math.floor(totalSec / 60);
|
|
14
22
|
const sec = totalSec % 60;
|
|
15
23
|
return `${min}:${sec.toString().padStart(2, '0')}`;
|
|
16
24
|
};
|
|
17
25
|
|
|
18
|
-
|
|
26
|
+
type AudioMessageProps = {
|
|
27
|
+
src: string;
|
|
28
|
+
mimeType?: string;
|
|
29
|
+
fileName?: string;
|
|
30
|
+
originalName?: string;
|
|
31
|
+
duration?: number | string;
|
|
32
|
+
waveForm?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type AudioContainer =
|
|
36
|
+
| 'webm'
|
|
37
|
+
| 'ogg'
|
|
38
|
+
| 'wav'
|
|
39
|
+
| 'mp4'
|
|
40
|
+
| 'mp3'
|
|
41
|
+
| 'aac'
|
|
42
|
+
| 'unknown';
|
|
43
|
+
|
|
44
|
+
type PreparedSource = {
|
|
45
|
+
localUri: string;
|
|
46
|
+
container: AudioContainer;
|
|
47
|
+
base64?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type WebDecoderMessage =
|
|
51
|
+
| { type: 'bridge_ready' }
|
|
52
|
+
| { type: 'decoded'; durationMillis: number; wavBase64: string }
|
|
53
|
+
| { type: 'error'; message?: string };
|
|
54
|
+
|
|
55
|
+
// Largest payload we will pull into JS for decoding (input opus) and the
|
|
56
|
+
// largest WAV we will ship back over the bridge (output PCM). Voice
|
|
57
|
+
// messages are a few dozen KB / a couple hundred KB of WAV; these only
|
|
58
|
+
// guard against a pathological multi-minute attachment.
|
|
59
|
+
const MAX_DECODE_BYTES = 25 * 1024 * 1024;
|
|
60
|
+
|
|
61
|
+
const hashString = (value: string) => {
|
|
62
|
+
let hash = 0;
|
|
63
|
+
for (let i = 0; i < value.length; i += 1) {
|
|
64
|
+
hash = (hash * 31 + value.charCodeAt(i)) | 0;
|
|
65
|
+
}
|
|
66
|
+
return Math.abs(hash).toString(36);
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const withTimeout = async <T,>(
|
|
70
|
+
promise: Promise<T>,
|
|
71
|
+
ms: number,
|
|
72
|
+
label: string
|
|
73
|
+
) => {
|
|
74
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
75
|
+
try {
|
|
76
|
+
return await Promise.race([
|
|
77
|
+
promise,
|
|
78
|
+
new Promise<T>((_, reject) => {
|
|
79
|
+
timer = setTimeout(() => reject(new Error(label)), ms);
|
|
80
|
+
}),
|
|
81
|
+
]);
|
|
82
|
+
} finally {
|
|
83
|
+
if (timer) {
|
|
84
|
+
clearTimeout(timer);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const logAudioDebug = (message: string, details?: Record<string, unknown>) => {
|
|
90
|
+
if (typeof __DEV__ !== 'undefined' && __DEV__) {
|
|
91
|
+
devPushLog('rn', message, details);
|
|
92
|
+
console.log(`[AudioDebug] ${message}`, details || {});
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const B64_ALPHABET =
|
|
97
|
+
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
98
|
+
|
|
99
|
+
// Decode just the leading bytes of a base64 string into a byte array. Used
|
|
100
|
+
// for magic-number container sniffing — we only need the first ~16 bytes,
|
|
101
|
+
// so this avoids pulling a base64 lib in for a handful of bytes.
|
|
102
|
+
const decodeBase64Head = (b64: string, maxBytes: number): number[] => {
|
|
103
|
+
const out: number[] = [];
|
|
104
|
+
let buffer = 0;
|
|
105
|
+
let bits = 0;
|
|
106
|
+
for (let i = 0; i < b64.length && out.length < maxBytes; i += 1) {
|
|
107
|
+
const ch = b64[i]!;
|
|
108
|
+
if (ch === '=') {
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
const idx = B64_ALPHABET.indexOf(ch);
|
|
112
|
+
if (idx === -1) {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
buffer = (buffer << 6) | idx;
|
|
116
|
+
bits += 6;
|
|
117
|
+
if (bits >= 8) {
|
|
118
|
+
bits -= 8;
|
|
119
|
+
out.push((buffer >> bits) & 0xff);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Sniff the real container from magic bytes. This is the source of truth —
|
|
126
|
+
// the HTTP mime is frequently `application/octet-stream` and the filename a
|
|
127
|
+
// meaningless `.blob`/`.bin`, so neither can be trusted. Byte signatures:
|
|
128
|
+
// EBML 1A45DFA3 → webm/matroska, OggS → ogg, RIFF…WAVE → wav,
|
|
129
|
+
// ….ftyp → mp4/m4a, ID3 / FFEx → mp3, FFF1/FFF9 → aac(ADTS).
|
|
130
|
+
const detectContainer = (bytes: number[]): AudioContainer => {
|
|
131
|
+
if (bytes.length < 4) {
|
|
132
|
+
return 'unknown';
|
|
133
|
+
}
|
|
134
|
+
if (
|
|
135
|
+
bytes[0] === 0x1a &&
|
|
136
|
+
bytes[1] === 0x45 &&
|
|
137
|
+
bytes[2] === 0xdf &&
|
|
138
|
+
bytes[3] === 0xa3
|
|
139
|
+
) {
|
|
140
|
+
return 'webm';
|
|
141
|
+
}
|
|
142
|
+
if (
|
|
143
|
+
bytes[0] === 0x4f &&
|
|
144
|
+
bytes[1] === 0x67 &&
|
|
145
|
+
bytes[2] === 0x67 &&
|
|
146
|
+
bytes[3] === 0x53
|
|
147
|
+
) {
|
|
148
|
+
return 'ogg';
|
|
149
|
+
}
|
|
150
|
+
if (
|
|
151
|
+
bytes[0] === 0x52 &&
|
|
152
|
+
bytes[1] === 0x49 &&
|
|
153
|
+
bytes[2] === 0x46 &&
|
|
154
|
+
bytes[3] === 0x46 &&
|
|
155
|
+
bytes[8] === 0x57 &&
|
|
156
|
+
bytes[9] === 0x41 &&
|
|
157
|
+
bytes[10] === 0x56 &&
|
|
158
|
+
bytes[11] === 0x45
|
|
159
|
+
) {
|
|
160
|
+
return 'wav';
|
|
161
|
+
}
|
|
162
|
+
if (
|
|
163
|
+
bytes[4] === 0x66 &&
|
|
164
|
+
bytes[5] === 0x74 &&
|
|
165
|
+
bytes[6] === 0x79 &&
|
|
166
|
+
bytes[7] === 0x70
|
|
167
|
+
) {
|
|
168
|
+
return 'mp4';
|
|
169
|
+
}
|
|
170
|
+
if (bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33) {
|
|
171
|
+
return 'mp3';
|
|
172
|
+
}
|
|
173
|
+
if (bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0) {
|
|
174
|
+
// MPEG audio frame sync (mp3) or ADTS AAC — both decode natively.
|
|
175
|
+
return bytes[1] === 0xf1 || bytes[1] === 0xf9 ? 'aac' : 'mp3';
|
|
176
|
+
}
|
|
177
|
+
return 'unknown';
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const getContainerExtension = (container: AudioContainer) => {
|
|
181
|
+
switch (container) {
|
|
182
|
+
case 'webm':
|
|
183
|
+
return '.webm';
|
|
184
|
+
case 'ogg':
|
|
185
|
+
return '.ogg';
|
|
186
|
+
case 'wav':
|
|
187
|
+
return '.wav';
|
|
188
|
+
case 'mp4':
|
|
189
|
+
return '.m4a';
|
|
190
|
+
case 'mp3':
|
|
191
|
+
return '.mp3';
|
|
192
|
+
case 'aac':
|
|
193
|
+
return '.aac';
|
|
194
|
+
default:
|
|
195
|
+
return '.bin';
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// AVFoundation (expo-av) and the iOS WKWebView <audio> element cannot
|
|
200
|
+
// demux/decode WebM or Ogg Opus — only `AudioContext.decodeAudioData` can
|
|
201
|
+
// on iOS. So on iOS those two containers go through the WebView decoder.
|
|
202
|
+
// Everything else (mp3/m4a/aac/wav), and ALL of Android (ExoPlayer handles
|
|
203
|
+
// Opus natively), plays straight through expo-av.
|
|
204
|
+
const requiresWebAudioDecode = (container: AudioContainer) =>
|
|
205
|
+
Platform.OS === 'ios' && (container === 'webm' || container === 'ogg');
|
|
206
|
+
|
|
207
|
+
// The WebView is used ONLY as a decoder, never as a player. Two iOS facts
|
|
208
|
+
// force this shape:
|
|
209
|
+
// • `decodeAudioData` is the only iOS API that understands WebM/Ogg Opus,
|
|
210
|
+
// and it works on a suspended AudioContext (decoding needs no gesture).
|
|
211
|
+
// • Playing audio OUT of a WebView — via AudioContext OR an <audio>
|
|
212
|
+
// element — is gated behind an in-page user gesture that an RN-side tap
|
|
213
|
+
// cannot supply, so both hang silently.
|
|
214
|
+
// Therefore the page decodes Opus → PCM, re-encodes to a WAV blob, and ships
|
|
215
|
+
// the bytes back to RN as base64. RN writes the WAV to cache and plays it
|
|
216
|
+
// through expo-av (native, no gesture limits, real play/pause/seek).
|
|
217
|
+
const WEB_DECODER_HTML = `<!doctype html>
|
|
218
|
+
<html>
|
|
219
|
+
<head>
|
|
220
|
+
<meta charset="utf-8" />
|
|
221
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
222
|
+
<style>html,body{margin:0;padding:0;width:1px;height:1px;overflow:hidden;background:transparent}</style>
|
|
223
|
+
</head>
|
|
224
|
+
<body>
|
|
225
|
+
<script>
|
|
226
|
+
(function () {
|
|
227
|
+
var MAX_WAV = ${MAX_DECODE_BYTES};
|
|
228
|
+
var post = function (payload) {
|
|
229
|
+
if (window.ReactNativeWebView) {
|
|
230
|
+
window.ReactNativeWebView.postMessage(JSON.stringify(payload));
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
var ctx = null;
|
|
234
|
+
var encodeWav = function (buffer) {
|
|
235
|
+
var numCh = buffer.numberOfChannels;
|
|
236
|
+
var frames = buffer.length;
|
|
237
|
+
var sr = buffer.sampleRate;
|
|
238
|
+
var bytesLen = 44 + frames * numCh * 2;
|
|
239
|
+
var ab = new ArrayBuffer(bytesLen);
|
|
240
|
+
var view = new DataView(ab);
|
|
241
|
+
var off = 0;
|
|
242
|
+
var ws = function (s) { for (var i = 0; i < s.length; i += 1) { view.setUint8(off++, s.charCodeAt(i)); } };
|
|
243
|
+
var u32 = function (d) { view.setUint32(off, d, true); off += 4; };
|
|
244
|
+
var u16 = function (d) { view.setUint16(off, d, true); off += 2; };
|
|
245
|
+
ws('RIFF'); u32(bytesLen - 8); ws('WAVE');
|
|
246
|
+
ws('fmt '); u32(16); u16(1); u16(numCh); u32(sr); u32(sr * numCh * 2); u16(numCh * 2); u16(16);
|
|
247
|
+
ws('data'); u32(frames * numCh * 2);
|
|
248
|
+
var chans = [];
|
|
249
|
+
for (var c = 0; c < numCh; c += 1) { chans.push(buffer.getChannelData(c)); }
|
|
250
|
+
for (var f = 0; f < frames; f += 1) {
|
|
251
|
+
for (var c2 = 0; c2 < numCh; c2 += 1) {
|
|
252
|
+
var s = Math.max(-1, Math.min(1, chans[c2][f]));
|
|
253
|
+
view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
|
254
|
+
off += 2;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return ab;
|
|
258
|
+
};
|
|
259
|
+
var abToBase64 = function (ab) {
|
|
260
|
+
var bytes = new Uint8Array(ab);
|
|
261
|
+
var binary = '';
|
|
262
|
+
var chunk = 0x8000;
|
|
263
|
+
for (var i = 0; i < bytes.length; i += chunk) {
|
|
264
|
+
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + chunk));
|
|
265
|
+
}
|
|
266
|
+
return window.btoa(binary);
|
|
267
|
+
};
|
|
268
|
+
window.__decodeAudio = function (base64) {
|
|
269
|
+
try {
|
|
270
|
+
if (!ctx) {
|
|
271
|
+
var Ctx = window.AudioContext || window.webkitAudioContext;
|
|
272
|
+
ctx = new Ctx();
|
|
273
|
+
}
|
|
274
|
+
var bin = window.atob(base64);
|
|
275
|
+
var len = bin.length;
|
|
276
|
+
var bytes = new Uint8Array(len);
|
|
277
|
+
for (var i = 0; i < len; i += 1) { bytes[i] = bin.charCodeAt(i); }
|
|
278
|
+
ctx.decodeAudioData(
|
|
279
|
+
bytes.buffer,
|
|
280
|
+
function (decoded) {
|
|
281
|
+
try {
|
|
282
|
+
var wav = encodeWav(decoded);
|
|
283
|
+
if (wav.byteLength > MAX_WAV) {
|
|
284
|
+
post({ type: 'error', message: 'audio_too_long' });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
post({
|
|
288
|
+
type: 'decoded',
|
|
289
|
+
durationMillis: Math.floor(decoded.duration * 1000),
|
|
290
|
+
wavBase64: abToBase64(wav),
|
|
291
|
+
});
|
|
292
|
+
} catch (e) {
|
|
293
|
+
post({ type: 'error', message: 'encode_failed:' + (e && e.message ? e.message : e) });
|
|
294
|
+
}
|
|
295
|
+
},
|
|
296
|
+
function (err) {
|
|
297
|
+
post({ type: 'error', message: 'decode_failed:' + (err && err.message ? err.message : err) });
|
|
298
|
+
}
|
|
299
|
+
);
|
|
300
|
+
} catch (e) {
|
|
301
|
+
post({ type: 'error', message: 'load_failed:' + (e && e.message ? e.message : e) });
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
post({ type: 'bridge_ready' });
|
|
305
|
+
})();
|
|
306
|
+
</script>
|
|
307
|
+
</body>
|
|
308
|
+
</html>`;
|
|
309
|
+
|
|
310
|
+
const AudioMessage = ({
|
|
311
|
+
src,
|
|
312
|
+
mimeType,
|
|
313
|
+
fileName,
|
|
314
|
+
originalName,
|
|
315
|
+
}: AudioMessageProps) => {
|
|
316
|
+
const { config } = useChatSettingState();
|
|
19
317
|
const soundRef = useRef<Audio.Sound | null>(null);
|
|
318
|
+
const webViewRef = useRef<WebView | null>(null);
|
|
319
|
+
const preparedRef = useRef<PreparedSource | null>(null);
|
|
320
|
+
const bridgeReadyRef = useRef(false);
|
|
321
|
+
const injectedRef = useRef(false);
|
|
322
|
+
const didFinishRef = useRef(false);
|
|
323
|
+
const unmountedRef = useRef(false);
|
|
324
|
+
const loadingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
325
|
+
|
|
20
326
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
21
327
|
const [isLoading, setIsLoading] = useState(false);
|
|
328
|
+
const [playbackError, setPlaybackError] = useState(false);
|
|
22
329
|
const [position, setPosition] = useState(0);
|
|
23
330
|
const [duration, setDuration] = useState(0);
|
|
331
|
+
const [webViewEnabled, setWebViewEnabled] = useState(false);
|
|
332
|
+
const primaryColor = config?.colors?.primary || '#0A84FF';
|
|
333
|
+
|
|
334
|
+
const clearLoadingGuard = () => {
|
|
335
|
+
if (loadingTimerRef.current) {
|
|
336
|
+
clearTimeout(loadingTimerRef.current);
|
|
337
|
+
loadingTimerRef.current = null;
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const fail = (reason: unknown) => {
|
|
342
|
+
clearLoadingGuard();
|
|
343
|
+
if (unmountedRef.current) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
logAudioDebug('audio playback failed', {
|
|
347
|
+
src,
|
|
348
|
+
mimeType,
|
|
349
|
+
fileName,
|
|
350
|
+
originalName,
|
|
351
|
+
container: preparedRef.current?.container,
|
|
352
|
+
error:
|
|
353
|
+
reason instanceof Error
|
|
354
|
+
? { name: reason.name, message: reason.message }
|
|
355
|
+
: String(reason),
|
|
356
|
+
});
|
|
357
|
+
setIsLoading(false);
|
|
358
|
+
setIsPlaying(false);
|
|
359
|
+
setPlaybackError(true);
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// Hard ceiling on the loading spinner — whatever stalls (a hung network
|
|
363
|
+
// request, a WebView that never decodes, expo-av wedging on a bad file)
|
|
364
|
+
// the control resolves to an error state instead of spinning forever
|
|
365
|
+
// (the customer-reported "stuck spinner").
|
|
366
|
+
const armLoadingGuard = () => {
|
|
367
|
+
clearLoadingGuard();
|
|
368
|
+
loadingTimerRef.current = setTimeout(() => {
|
|
369
|
+
if (!unmountedRef.current && !soundRef.current) {
|
|
370
|
+
fail(new Error('audio_timeout'));
|
|
371
|
+
}
|
|
372
|
+
}, 15000);
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const prepareSource = async (): Promise<PreparedSource> => {
|
|
376
|
+
if (preparedRef.current) {
|
|
377
|
+
return preparedRef.current;
|
|
378
|
+
}
|
|
379
|
+
if (!/^https?:\/\//i.test(src)) {
|
|
380
|
+
const prepared: PreparedSource = { localUri: src, container: 'unknown' };
|
|
381
|
+
preparedRef.current = prepared;
|
|
382
|
+
return prepared;
|
|
383
|
+
}
|
|
384
|
+
const cacheDirectory = FileSystem.cacheDirectory;
|
|
385
|
+
if (!cacheDirectory) {
|
|
386
|
+
const prepared: PreparedSource = { localUri: src, container: 'unknown' };
|
|
387
|
+
preparedRef.current = prepared;
|
|
388
|
+
return prepared;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
const base = `${cacheDirectory}ethora-audio-${hashString(src)}`;
|
|
392
|
+
const part = `${base}.part`;
|
|
393
|
+
const partInfo = await FileSystem.getInfoAsync(part);
|
|
394
|
+
if (!partInfo.exists) {
|
|
395
|
+
const download = await withTimeout(
|
|
396
|
+
FileSystem.downloadAsync(src, part),
|
|
397
|
+
12000,
|
|
398
|
+
'audio_download_timeout'
|
|
399
|
+
);
|
|
400
|
+
logAudioDebug('audio downloaded', {
|
|
401
|
+
src,
|
|
402
|
+
uri: download.status === 200 ? download.uri : undefined,
|
|
403
|
+
status: download.status,
|
|
404
|
+
});
|
|
405
|
+
if (download.status !== 200) {
|
|
406
|
+
throw new Error(`audio_download_status_${download.status}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const head = await FileSystem.readAsStringAsync(part, {
|
|
411
|
+
encoding: FileSystem.EncodingType.Base64,
|
|
412
|
+
position: 0,
|
|
413
|
+
length: 32,
|
|
414
|
+
});
|
|
415
|
+
const container = detectContainer(decodeBase64Head(head, 16));
|
|
416
|
+
|
|
417
|
+
let base64: string | undefined;
|
|
418
|
+
let localUri = part;
|
|
419
|
+
if (requiresWebAudioDecode(container)) {
|
|
420
|
+
// WebView-decode path: extension is irrelevant, we hand over the bytes.
|
|
421
|
+
const info = await FileSystem.getInfoAsync(part);
|
|
422
|
+
const size = 'size' in info ? info.size ?? 0 : 0;
|
|
423
|
+
if (size > MAX_DECODE_BYTES) {
|
|
424
|
+
throw new Error('audio_too_large_to_decode');
|
|
425
|
+
}
|
|
426
|
+
base64 = await FileSystem.readAsStringAsync(part, {
|
|
427
|
+
encoding: FileSystem.EncodingType.Base64,
|
|
428
|
+
});
|
|
429
|
+
} else {
|
|
430
|
+
// Native path: give the cached file a real extension so AVFoundation
|
|
431
|
+
// / ExoPlayer pick the right demuxer instead of choking on an opaque
|
|
432
|
+
// `.blob` (the original `audio_timeout` cause).
|
|
433
|
+
const ext =
|
|
434
|
+
container !== 'unknown'
|
|
435
|
+
? getContainerExtension(container)
|
|
436
|
+
: getIosAudioPlaybackCacheExtension({
|
|
437
|
+
mime: mimeType,
|
|
438
|
+
fileName,
|
|
439
|
+
originalName,
|
|
440
|
+
url: src,
|
|
441
|
+
});
|
|
442
|
+
const finalUri = `${base}${ext}`;
|
|
443
|
+
const finalInfo = await FileSystem.getInfoAsync(finalUri);
|
|
444
|
+
if (finalInfo.exists) {
|
|
445
|
+
await FileSystem.deleteAsync(part, { idempotent: true }).catch(
|
|
446
|
+
() => {}
|
|
447
|
+
);
|
|
448
|
+
} else {
|
|
449
|
+
await FileSystem.moveAsync({ from: part, to: finalUri });
|
|
450
|
+
}
|
|
451
|
+
localUri = finalUri;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const prepared: PreparedSource = { localUri, container, base64 };
|
|
455
|
+
preparedRef.current = prepared;
|
|
456
|
+
logAudioDebug('audio prepared', {
|
|
457
|
+
src,
|
|
458
|
+
mimeType,
|
|
459
|
+
fileName,
|
|
460
|
+
originalName,
|
|
461
|
+
container,
|
|
462
|
+
platform: Platform.OS,
|
|
463
|
+
decode: requiresWebAudioDecode(container) ? 'web-audio' : 'native',
|
|
464
|
+
});
|
|
465
|
+
return prepared;
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const maybeInjectDecode = () => {
|
|
469
|
+
const base64 = preparedRef.current?.base64;
|
|
470
|
+
if (
|
|
471
|
+
bridgeReadyRef.current &&
|
|
472
|
+
base64 &&
|
|
473
|
+
!injectedRef.current &&
|
|
474
|
+
webViewRef.current
|
|
475
|
+
) {
|
|
476
|
+
injectedRef.current = true;
|
|
477
|
+
webViewRef.current.injectJavaScript(
|
|
478
|
+
`window.__decodeAudio(${JSON.stringify(base64)}); true;`
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
};
|
|
24
482
|
|
|
25
483
|
const onPlaybackStatusUpdate = (status: AVPlaybackStatus) => {
|
|
26
|
-
if (!status.isLoaded)
|
|
484
|
+
if (!status.isLoaded) {
|
|
485
|
+
if (status.error) {
|
|
486
|
+
logAudioDebug('playback status error', { src, error: status.error });
|
|
487
|
+
}
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
27
490
|
setPosition(status.positionMillis);
|
|
28
491
|
setDuration(status.durationMillis ?? 0);
|
|
29
492
|
setIsPlaying(status.isPlaying);
|
|
30
493
|
if (status.didJustFinish) {
|
|
494
|
+
didFinishRef.current = true;
|
|
31
495
|
setIsPlaying(false);
|
|
32
|
-
|
|
496
|
+
setPosition(0);
|
|
497
|
+
void soundRef.current?.setStatusAsync({
|
|
498
|
+
shouldPlay: false,
|
|
499
|
+
positionMillis: 0,
|
|
500
|
+
});
|
|
33
501
|
}
|
|
34
502
|
};
|
|
35
503
|
|
|
36
|
-
const
|
|
504
|
+
const startNativePlayback = async (localUri: string) => {
|
|
37
505
|
try {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
506
|
+
await Audio.setAudioModeAsync({ playsInSilentModeIOS: true });
|
|
507
|
+
} catch {
|
|
508
|
+
/* non-fatal — still plays through the ringer channel */
|
|
509
|
+
}
|
|
510
|
+
const { sound } = await withTimeout<{
|
|
511
|
+
sound: Audio.Sound;
|
|
512
|
+
status: AVPlaybackStatus;
|
|
513
|
+
}>(
|
|
514
|
+
Audio.Sound.createAsync(
|
|
515
|
+
{ uri: localUri },
|
|
516
|
+
{ shouldPlay: true },
|
|
517
|
+
onPlaybackStatusUpdate
|
|
518
|
+
),
|
|
519
|
+
12000,
|
|
520
|
+
'audio_create_timeout'
|
|
521
|
+
);
|
|
522
|
+
if (unmountedRef.current) {
|
|
523
|
+
void sound.unloadAsync();
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
soundRef.current = sound;
|
|
527
|
+
clearLoadingGuard();
|
|
528
|
+
setIsLoading(false);
|
|
529
|
+
setPlaybackError(false);
|
|
530
|
+
setIsPlaying(true);
|
|
531
|
+
logAudioDebug('native audio playback started', {
|
|
532
|
+
src,
|
|
533
|
+
container: preparedRef.current?.container,
|
|
534
|
+
localUri,
|
|
535
|
+
});
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
// WebView finished decoding Opus → WAV: persist the WAV and play it
|
|
539
|
+
// natively. From here it is indistinguishable from any other native clip.
|
|
540
|
+
const onDecoded = async (durationMillis: number, wavBase64: string) => {
|
|
541
|
+
try {
|
|
542
|
+
const cacheDirectory = FileSystem.cacheDirectory || '';
|
|
543
|
+
const wavUri = `${cacheDirectory}ethora-audio-${hashString(src)}.wav`;
|
|
544
|
+
await FileSystem.writeAsStringAsync(wavUri, wavBase64, {
|
|
545
|
+
encoding: FileSystem.EncodingType.Base64,
|
|
546
|
+
});
|
|
547
|
+
if (unmountedRef.current) {
|
|
48
548
|
return;
|
|
49
549
|
}
|
|
550
|
+
preparedRef.current = {
|
|
551
|
+
localUri: wavUri,
|
|
552
|
+
container: preparedRef.current?.container ?? 'webm',
|
|
553
|
+
};
|
|
554
|
+
if (durationMillis > 0) {
|
|
555
|
+
setDuration(durationMillis);
|
|
556
|
+
}
|
|
557
|
+
setWebViewEnabled(false); // decoder no longer needed
|
|
558
|
+
await startNativePlayback(wavUri);
|
|
559
|
+
} catch (error) {
|
|
560
|
+
fail(error);
|
|
561
|
+
}
|
|
562
|
+
};
|
|
50
563
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
564
|
+
const handleWebMessage = (event: WebViewMessageEvent) => {
|
|
565
|
+
let payload: WebDecoderMessage;
|
|
566
|
+
try {
|
|
567
|
+
payload = JSON.parse(event.nativeEvent.data) as WebDecoderMessage;
|
|
568
|
+
} catch {
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (payload.type === 'bridge_ready') {
|
|
572
|
+
bridgeReadyRef.current = true;
|
|
573
|
+
maybeInjectDecode();
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (payload.type === 'decoded') {
|
|
577
|
+
logAudioDebug('web audio decoded', {
|
|
578
|
+
src,
|
|
579
|
+
durationMillis: payload.durationMillis,
|
|
580
|
+
wavBytes: payload.wavBase64 ? payload.wavBase64.length : 0,
|
|
581
|
+
});
|
|
582
|
+
void onDecoded(payload.durationMillis, payload.wavBase64);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
// type === 'error'
|
|
586
|
+
fail(new Error(payload.message || 'web_audio_error'));
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const togglePlayback = async () => {
|
|
590
|
+
if (!src) {
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Already-loaded native sound (covers both native formats AND the
|
|
595
|
+
// decoded-WAV from the WebView path) → plain toggle.
|
|
596
|
+
if (soundRef.current) {
|
|
597
|
+
try {
|
|
598
|
+
if (isPlaying) {
|
|
599
|
+
await soundRef.current.pauseAsync();
|
|
600
|
+
} else {
|
|
601
|
+
if (didFinishRef.current) {
|
|
602
|
+
await soundRef.current.setPositionAsync(0);
|
|
603
|
+
didFinishRef.current = false;
|
|
604
|
+
}
|
|
605
|
+
await soundRef.current.playAsync();
|
|
606
|
+
}
|
|
607
|
+
} catch (error) {
|
|
608
|
+
fail(error);
|
|
55
609
|
}
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// First tap → prepare (download + sniff) then route to the right engine.
|
|
614
|
+
setPlaybackError(false);
|
|
615
|
+
setIsLoading(true);
|
|
616
|
+
armLoadingGuard();
|
|
617
|
+
try {
|
|
618
|
+
const prepared = await withTimeout(
|
|
619
|
+
prepareSource(),
|
|
620
|
+
14000,
|
|
621
|
+
'audio_prepare_timeout'
|
|
622
|
+
);
|
|
623
|
+
if (unmountedRef.current) {
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (requiresWebAudioDecode(prepared.container)) {
|
|
627
|
+
// Mount the decoder WebView; decoding + playback continue when the
|
|
628
|
+
// 'decoded' message arrives (→ onDecoded → startNativePlayback).
|
|
629
|
+
if (!webViewEnabled) {
|
|
630
|
+
setWebViewEnabled(true);
|
|
631
|
+
}
|
|
632
|
+
maybeInjectDecode();
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
await startNativePlayback(prepared.localUri);
|
|
56
636
|
} catch (error) {
|
|
57
|
-
|
|
58
|
-
setIsLoading(false);
|
|
637
|
+
fail(error);
|
|
59
638
|
}
|
|
60
639
|
};
|
|
61
640
|
|
|
62
641
|
useEffect(() => {
|
|
642
|
+
unmountedRef.current = false;
|
|
63
643
|
return () => {
|
|
644
|
+
unmountedRef.current = true;
|
|
645
|
+
clearLoadingGuard();
|
|
64
646
|
soundRef.current?.unloadAsync();
|
|
65
647
|
};
|
|
648
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
66
649
|
}, []);
|
|
67
650
|
|
|
68
|
-
|
|
651
|
+
// Full reset whenever the source changes (component reused for a new
|
|
652
|
+
// message / re-render with a different src).
|
|
653
|
+
useEffect(() => {
|
|
654
|
+
soundRef.current?.unloadAsync();
|
|
655
|
+
soundRef.current = null;
|
|
656
|
+
preparedRef.current = null;
|
|
657
|
+
bridgeReadyRef.current = false;
|
|
658
|
+
injectedRef.current = false;
|
|
659
|
+
didFinishRef.current = false;
|
|
660
|
+
clearLoadingGuard();
|
|
661
|
+
setIsPlaying(false);
|
|
662
|
+
setIsLoading(false);
|
|
663
|
+
setPlaybackError(false);
|
|
664
|
+
setPosition(0);
|
|
665
|
+
setDuration(0);
|
|
666
|
+
setWebViewEnabled(false);
|
|
667
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
668
|
+
}, [src]);
|
|
669
|
+
|
|
670
|
+
const progress = duration > 0 ? Math.min(1, position / duration) : 0;
|
|
69
671
|
|
|
70
672
|
return (
|
|
71
|
-
<View style={styles.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
673
|
+
<View style={styles.root}>
|
|
674
|
+
{/* Decoder WebView lives in a zero-size, absolutely-positioned host
|
|
675
|
+
OUTSIDE the row so mounting/unmounting it during the loading phase
|
|
676
|
+
can never shift the button or progress line (was the flicker). */}
|
|
677
|
+
{webViewEnabled ? (
|
|
678
|
+
<View style={styles.decoderHost} pointerEvents="none">
|
|
679
|
+
<WebView
|
|
680
|
+
ref={webViewRef}
|
|
681
|
+
source={{ html: WEB_DECODER_HTML }}
|
|
682
|
+
originWhitelist={['*']}
|
|
683
|
+
onMessage={handleWebMessage}
|
|
684
|
+
javaScriptEnabled
|
|
685
|
+
domStorageEnabled={false}
|
|
686
|
+
scrollEnabled={false}
|
|
687
|
+
pointerEvents="none"
|
|
688
|
+
showsHorizontalScrollIndicator={false}
|
|
689
|
+
showsVerticalScrollIndicator={false}
|
|
690
|
+
style={styles.hiddenWebView}
|
|
691
|
+
/>
|
|
692
|
+
</View>
|
|
693
|
+
) : null}
|
|
694
|
+
<View style={styles.container}>
|
|
695
|
+
<TouchableOpacity
|
|
696
|
+
style={[styles.playButton, { backgroundColor: primaryColor }]}
|
|
697
|
+
onPress={togglePlayback}
|
|
698
|
+
disabled={isLoading}
|
|
699
|
+
activeOpacity={0.85}
|
|
700
|
+
>
|
|
701
|
+
{isLoading ? (
|
|
702
|
+
<ActivityIndicator size="small" color="#fff" />
|
|
703
|
+
) : (
|
|
704
|
+
<View style={styles.iconWrap}>
|
|
705
|
+
{isPlaying ? (
|
|
706
|
+
<PauseIcon width={18} height={18} color="#fff" />
|
|
707
|
+
) : (
|
|
708
|
+
<PlayIcon width={18} height={18} color="#fff" />
|
|
709
|
+
)}
|
|
710
|
+
</View>
|
|
711
|
+
)}
|
|
712
|
+
</TouchableOpacity>
|
|
713
|
+
<View style={styles.progressContainer}>
|
|
714
|
+
<View style={styles.progressTrack}>
|
|
715
|
+
<View
|
|
716
|
+
style={[
|
|
717
|
+
styles.progressFill,
|
|
718
|
+
{ width: `${progress * 100}%`, backgroundColor: primaryColor },
|
|
719
|
+
]}
|
|
720
|
+
/>
|
|
721
|
+
</View>
|
|
722
|
+
<View style={styles.progressRow}>
|
|
723
|
+
<Text style={styles.time}>
|
|
724
|
+
{playbackError ? 'Audio unavailable' : formatTime(position)}
|
|
725
|
+
</Text>
|
|
726
|
+
{/* Always render the right slot (empty until known) so the row
|
|
727
|
+
never reflows when the duration appears after decoding. */}
|
|
728
|
+
<Text style={styles.time}>
|
|
729
|
+
{!playbackError && duration > 0 ? formatTime(duration) : ''}
|
|
730
|
+
</Text>
|
|
731
|
+
</View>
|
|
86
732
|
</View>
|
|
87
|
-
<Text style={styles.time}>
|
|
88
|
-
{formatTime(position)} / {formatTime(duration)}
|
|
89
|
-
</Text>
|
|
90
733
|
</View>
|
|
91
734
|
</View>
|
|
92
735
|
);
|
|
@@ -95,43 +738,65 @@ const AudioMessage = ({ src }: { src: string }) => {
|
|
|
95
738
|
export default AudioMessage;
|
|
96
739
|
|
|
97
740
|
const styles = StyleSheet.create({
|
|
741
|
+
root: {
|
|
742
|
+
position: 'relative',
|
|
743
|
+
},
|
|
744
|
+
decoderHost: {
|
|
745
|
+
position: 'absolute',
|
|
746
|
+
top: 0,
|
|
747
|
+
left: 0,
|
|
748
|
+
width: 0,
|
|
749
|
+
height: 0,
|
|
750
|
+
overflow: 'hidden',
|
|
751
|
+
opacity: 0,
|
|
752
|
+
},
|
|
98
753
|
container: {
|
|
99
754
|
flexDirection: 'row',
|
|
100
755
|
alignItems: 'center',
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
756
|
+
gap: 12,
|
|
757
|
+
paddingHorizontal: 12,
|
|
758
|
+
paddingVertical: 10,
|
|
759
|
+
width: 260,
|
|
760
|
+
maxWidth: '100%',
|
|
104
761
|
},
|
|
105
762
|
playButton: {
|
|
106
|
-
width:
|
|
107
|
-
height:
|
|
108
|
-
borderRadius:
|
|
109
|
-
backgroundColor: '#007AFF',
|
|
763
|
+
width: 44,
|
|
764
|
+
height: 44,
|
|
765
|
+
borderRadius: 22,
|
|
110
766
|
alignItems: 'center',
|
|
111
767
|
justifyContent: 'center',
|
|
112
|
-
marginRight: 10,
|
|
113
768
|
},
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
fontSize: 14,
|
|
769
|
+
iconWrap: {
|
|
770
|
+
marginLeft: 1,
|
|
117
771
|
},
|
|
118
772
|
progressContainer: {
|
|
119
773
|
flex: 1,
|
|
774
|
+
gap: 6,
|
|
775
|
+
},
|
|
776
|
+
progressRow: {
|
|
777
|
+
flexDirection: 'row',
|
|
778
|
+
justifyContent: 'space-between',
|
|
779
|
+
alignItems: 'center',
|
|
120
780
|
},
|
|
121
781
|
progressTrack: {
|
|
122
|
-
height:
|
|
123
|
-
backgroundColor: '#
|
|
124
|
-
borderRadius:
|
|
782
|
+
height: 6,
|
|
783
|
+
backgroundColor: '#D0D7E6',
|
|
784
|
+
borderRadius: 999,
|
|
125
785
|
overflow: 'hidden',
|
|
126
786
|
},
|
|
787
|
+
hiddenWebView: {
|
|
788
|
+
position: 'absolute',
|
|
789
|
+
width: 1,
|
|
790
|
+
height: 1,
|
|
791
|
+
opacity: 0,
|
|
792
|
+
},
|
|
127
793
|
progressFill: {
|
|
128
794
|
height: '100%',
|
|
129
|
-
|
|
130
|
-
borderRadius: 2,
|
|
795
|
+
borderRadius: 999,
|
|
131
796
|
},
|
|
132
797
|
time: {
|
|
133
|
-
fontSize:
|
|
134
|
-
color: '#
|
|
135
|
-
|
|
798
|
+
fontSize: 12,
|
|
799
|
+
color: '#667085',
|
|
800
|
+
fontWeight: '500',
|
|
136
801
|
},
|
|
137
802
|
});
|