@adatechnology/conversations-ui 0.1.0-rc.12 → 0.1.0-rc.14
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/dist/{chunk-3RKFU46R.js → chunk-SOWV4264.js} +270 -109
- package/dist/index.d.ts +2 -2
- package/dist/index.js +7 -1
- package/dist/preview/index.d.ts +3 -37
- package/dist/preview/index.js +37 -125
- package/dist/{types-B5C1DLu1.d.ts → types-O7kMP1Yn.d.ts} +52 -1
- package/package.json +1 -1
- package/src/AudioRecorderButton.test.tsx +30 -0
- package/src/AudioRecorderButton.tsx +241 -0
- package/src/{preview/audioRecorderFormat.test.ts → audioRecorderFormat.test.ts} +1 -1
- package/src/index.ts +6 -0
- package/src/preview/ConversationPreview.tsx +2 -2
- package/src/preview/index.ts +2 -2
- package/src/preview/AudioRecorderButton.tsx +0 -155
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gravação de áudio no simulador, pelo microfone do próprio navegador.
|
|
3
|
+
*
|
|
4
|
+
* Existe porque áudio é o formato que mais chega de cliente real e o que mais quebra fluxo: sem
|
|
5
|
+
* poder gravar aqui, testar o caminho de transcrição exigia mandar mensagem do celular de alguém.
|
|
6
|
+
*
|
|
7
|
+
* O arquivo gravado sai daqui como `File` e segue exatamente o mesmo caminho de um anexo — quem
|
|
8
|
+
* hospeda e devolve o `mediaId` é o host, via `uploadMedia`.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useCallback, useEffect, useRef, useState } from 'react'
|
|
12
|
+
|
|
13
|
+
export interface AudioRecorderButtonLabels {
|
|
14
|
+
start: string
|
|
15
|
+
stop: string
|
|
16
|
+
unsupported: string
|
|
17
|
+
denied: string
|
|
18
|
+
review: string
|
|
19
|
+
send: string
|
|
20
|
+
discard: string
|
|
21
|
+
empty: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_AUDIO_RECORDER_BUTTON_LABELS: AudioRecorderButtonLabels = {
|
|
25
|
+
start: 'Gravar áudio',
|
|
26
|
+
stop: 'Parar gravação',
|
|
27
|
+
unsupported: 'Este navegador não grava áudio.',
|
|
28
|
+
denied: 'Sem permissão para usar o microfone.',
|
|
29
|
+
review: 'Ouça antes de enviar',
|
|
30
|
+
send: 'Enviar áudio',
|
|
31
|
+
discard: 'Descartar áudio',
|
|
32
|
+
empty: 'Nada foi captado pelo microfone.',
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AudioRecorderButtonProps {
|
|
36
|
+
onRecorded: (file: File) => void | Promise<void>
|
|
37
|
+
onFailure?: (message: string) => void
|
|
38
|
+
/**
|
|
39
|
+
* Avisa quando a gravação começa e termina. O botão é um interruptor — o segundo toque é que
|
|
40
|
+
* envia — e sem um aviso fora dele o operador grava, não vê nada acontecer e desiste achando
|
|
41
|
+
* que o microfone está quebrado.
|
|
42
|
+
*/
|
|
43
|
+
onRecordingChange?: (isRecording: boolean) => void
|
|
44
|
+
/**
|
|
45
|
+
* Abre uma etapa de revisão quando a gravação para: o áudio toca ali mesmo e só sai depois de
|
|
46
|
+
* confirmado. Ligado por padrão — voz é o único anexo que quem envia não viu antes de mandar, e
|
|
47
|
+
* sem ouvir não há como saber se o microfone captou alguma coisa. Desligar volta ao envio direto.
|
|
48
|
+
*/
|
|
49
|
+
reviewBeforeSend?: boolean
|
|
50
|
+
/**
|
|
51
|
+
* Teto de duração da gravação, em milissegundos. Passado o tempo, o gravador para e envia o que
|
|
52
|
+
* tem. Produto com limite próprio sobrescreve.
|
|
53
|
+
*/
|
|
54
|
+
maxDurationMilliseconds?: number
|
|
55
|
+
labels?: Partial<AudioRecorderButtonLabels>
|
|
56
|
+
disabled?: boolean
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Cinco minutos: com o codec de voz do WhatsApp isso dá menos de 3MB, folgado dentro do teto de
|
|
61
|
+
* 16MB que a Meta impõe a áudio, e é mais do que qualquer recado de cliente. O corte automático
|
|
62
|
+
* existe porque gravação esquecida aberta só se descobre no envio, com o arquivo inteiro perdido.
|
|
63
|
+
*/
|
|
64
|
+
export const DEFAULT_MAX_RECORDING_MILLISECONDS = 5 * 60 * 1000
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Ordem de preferência de formato: os dois primeiros o WhatsApp aceita como áudio; `webm` é só
|
|
68
|
+
* saída de emergência para navegador que não grava mais nada — gravar em webm e descobrir na hora
|
|
69
|
+
* do envio que o formato é inválido é pior do que gravar já no formato certo.
|
|
70
|
+
*/
|
|
71
|
+
const RECORDING_FORMATS = [
|
|
72
|
+
{ mimeType: 'audio/ogg;codecs=opus', uploadMimeType: 'audio/ogg', extension: 'ogg' },
|
|
73
|
+
{ mimeType: 'audio/mp4', uploadMimeType: 'audio/mp4', extension: 'm4a' },
|
|
74
|
+
{ mimeType: 'audio/webm', uploadMimeType: 'audio/webm', extension: 'webm' },
|
|
75
|
+
] as const
|
|
76
|
+
|
|
77
|
+
export type RecordingFormat = (typeof RECORDING_FORMATS)[number]
|
|
78
|
+
|
|
79
|
+
/** Primeiro formato que o navegador sabe gravar, ou `undefined` se não souber gravar nenhum. */
|
|
80
|
+
export function resolveRecordingFormat(): RecordingFormat | undefined {
|
|
81
|
+
if (typeof MediaRecorder === 'undefined') return undefined
|
|
82
|
+
// `isTypeSupported` não existe em toda implementação; onde falta, o primeiro da lista é o palpite.
|
|
83
|
+
if (typeof MediaRecorder.isTypeSupported !== 'function') return RECORDING_FORMATS[0]
|
|
84
|
+
return RECORDING_FORMATS.find((format) => MediaRecorder.isTypeSupported(format.mimeType))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type PendingRecording = { file: File; objectURL: string }
|
|
88
|
+
|
|
89
|
+
export function AudioRecorderButton({
|
|
90
|
+
onRecorded,
|
|
91
|
+
onFailure,
|
|
92
|
+
onRecordingChange,
|
|
93
|
+
reviewBeforeSend = true,
|
|
94
|
+
maxDurationMilliseconds = DEFAULT_MAX_RECORDING_MILLISECONDS,
|
|
95
|
+
labels,
|
|
96
|
+
disabled,
|
|
97
|
+
}: AudioRecorderButtonProps) {
|
|
98
|
+
const labelOf = (key: keyof AudioRecorderButtonLabels): string =>
|
|
99
|
+
labels?.[key] ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS[key]
|
|
100
|
+
const [isRecording, setIsRecording] = useState(false)
|
|
101
|
+
const [pending, setPending] = useState<PendingRecording | undefined>(undefined)
|
|
102
|
+
const recorderRef = useRef<MediaRecorder | null>(null)
|
|
103
|
+
const autoStopRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
104
|
+
|
|
105
|
+
// O `objectURL` é um recurso do documento, não do React: sem revogar, cada gravação descartada
|
|
106
|
+
// deixa o blob inteiro preso na memória da aba até um reload.
|
|
107
|
+
const discard = useCallback(() => {
|
|
108
|
+
setPending((current) => {
|
|
109
|
+
if (current) URL.revokeObjectURL(current.objectURL)
|
|
110
|
+
return undefined
|
|
111
|
+
})
|
|
112
|
+
}, [])
|
|
113
|
+
|
|
114
|
+
useEffect(() => discard, [discard])
|
|
115
|
+
|
|
116
|
+
const confirm = useCallback(() => {
|
|
117
|
+
if (!pending) return
|
|
118
|
+
void onRecorded(pending.file)
|
|
119
|
+
discard()
|
|
120
|
+
}, [discard, onRecorded, pending])
|
|
121
|
+
|
|
122
|
+
const stop = useCallback(() => {
|
|
123
|
+
recorderRef.current?.stop()
|
|
124
|
+
}, [])
|
|
125
|
+
|
|
126
|
+
const start = useCallback(async () => {
|
|
127
|
+
const format = resolveRecordingFormat()
|
|
128
|
+
if (!format || !navigator.mediaDevices?.getUserMedia) {
|
|
129
|
+
onFailure?.(labels?.unsupported ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.unsupported)
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
135
|
+
const recorder = new MediaRecorder(stream, { mimeType: format.mimeType })
|
|
136
|
+
const chunks: Blob[] = []
|
|
137
|
+
|
|
138
|
+
recorder.addEventListener('dataavailable', (event) => {
|
|
139
|
+
if (event.data.size > 0) chunks.push(event.data)
|
|
140
|
+
})
|
|
141
|
+
recorder.addEventListener('stop', () => {
|
|
142
|
+
// Solta o microfone assim que para: sem isto o indicador de gravação do navegador fica
|
|
143
|
+
// aceso depois do envio, e o operador acha que o simulador continua ouvindo.
|
|
144
|
+
stream.getTracks().forEach((track) => track.stop())
|
|
145
|
+
clearTimeout(autoStopRef.current)
|
|
146
|
+
setIsRecording(false)
|
|
147
|
+
onRecordingChange?.(false)
|
|
148
|
+
recorderRef.current = null
|
|
149
|
+
// O `File` sai com o MIME sem os parâmetros de codec: `audio/ogg;codecs=opus` serve ao
|
|
150
|
+
// gravador, mas quem valida upload compara com `audio/ogg` puro.
|
|
151
|
+
const blob = new Blob(chunks, { type: format.uploadMimeType })
|
|
152
|
+
const file = new File([blob], `audio-${Date.now()}.${format.extension}`, {
|
|
153
|
+
type: format.uploadMimeType,
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
// Gravação vazia não vira anexo: microfone mudo ou permissão revogada no meio produzem um
|
|
157
|
+
// blob de zero byte, e mandá-lo adiante só falha lá na frente, sem dizer por quê.
|
|
158
|
+
if (blob.size === 0) {
|
|
159
|
+
onFailure?.(labelOf('empty'))
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (!reviewBeforeSend) {
|
|
164
|
+
void onRecorded(file)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
setPending({ file, objectURL: URL.createObjectURL(blob) })
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
recorderRef.current = recorder
|
|
172
|
+
recorder.start()
|
|
173
|
+
autoStopRef.current = setTimeout(() => recorder.stop(), maxDurationMilliseconds)
|
|
174
|
+
setIsRecording(true)
|
|
175
|
+
onRecordingChange?.(true)
|
|
176
|
+
} catch {
|
|
177
|
+
onFailure?.(labels?.denied ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.denied)
|
|
178
|
+
}
|
|
179
|
+
// `labelOf` lê `labels` a cada render e não entra aqui; o que importa para recriar o gravador
|
|
180
|
+
// são o teto de duração, o destino da gravação e se há etapa de revisão.
|
|
181
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
182
|
+
}, [maxDurationMilliseconds, onFailure, onRecorded, onRecordingChange, reviewBeforeSend])
|
|
183
|
+
|
|
184
|
+
const toggleLabel = isRecording ? labelOf('stop') : labelOf('start')
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
/* O painel de revisão flutua sobre o botão em vez de ocupar espaço na barra: o microfone mora
|
|
188
|
+
na caixa do botão de enviar, e empurrar o composer para cima a cada gravação faria a
|
|
189
|
+
conversa saltar. */
|
|
190
|
+
<div className="relative flex-shrink-0">
|
|
191
|
+
{pending && (
|
|
192
|
+
<div
|
|
193
|
+
role="group"
|
|
194
|
+
aria-label={labelOf('review')}
|
|
195
|
+
className="absolute bottom-full right-0 z-20 mb-2 flex w-64 items-center gap-2 rounded-xl border border-gray-200 bg-white p-2 shadow-lg dark:border-gray-700 dark:bg-gray-800"
|
|
196
|
+
>
|
|
197
|
+
<audio src={pending.objectURL} controls className="h-8 min-w-0 flex-1" />
|
|
198
|
+
<button
|
|
199
|
+
type="button"
|
|
200
|
+
onClick={discard}
|
|
201
|
+
title={labelOf('discard')}
|
|
202
|
+
aria-label={labelOf('discard')}
|
|
203
|
+
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-red-500 dark:hover:bg-gray-700"
|
|
204
|
+
>
|
|
205
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
|
|
206
|
+
</button>
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
onClick={confirm}
|
|
210
|
+
title={labelOf('send')}
|
|
211
|
+
aria-label={labelOf('send')}
|
|
212
|
+
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-emerald-500 text-white transition-colors hover:bg-emerald-600"
|
|
213
|
+
>
|
|
214
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" /></svg>
|
|
215
|
+
</button>
|
|
216
|
+
</div>
|
|
217
|
+
)}
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
disabled={disabled || pending !== undefined}
|
|
221
|
+
onClick={() => (isRecording ? stop() : void start())}
|
|
222
|
+
title={toggleLabel}
|
|
223
|
+
aria-label={toggleLabel}
|
|
224
|
+
aria-pressed={isRecording}
|
|
225
|
+
/* Mesma caixa do botão de enviar: o microfone ocupa o lugar dele enquanto o campo está
|
|
226
|
+
vazio, e qualquer diferença de tamanho faz a barra pular a cada letra digitada. */
|
|
227
|
+
className={`flex h-10 w-10 items-center justify-center rounded-full transition-colors disabled:opacity-50 ${
|
|
228
|
+
isRecording
|
|
229
|
+
? 'animate-pulse bg-red-500 text-white ring-4 ring-red-500/30 hover:bg-red-600'
|
|
230
|
+
: 'text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
231
|
+
}`}
|
|
232
|
+
>
|
|
233
|
+
{isRecording ? (
|
|
234
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2" /></svg>
|
|
235
|
+
) : (
|
|
236
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" /><path d="M19 11a7 7 0 0 1-14 0" /><line x1="12" y1="18" x2="12" y2="22" /></svg>
|
|
237
|
+
)}
|
|
238
|
+
</button>
|
|
239
|
+
</div>
|
|
240
|
+
)
|
|
241
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from 'bun:test'
|
|
2
2
|
|
|
3
3
|
import { resolveRecordingFormat } from './AudioRecorderButton'
|
|
4
|
-
import { DEFAULT_ACCEPTED_FILE_TYPES } from '
|
|
4
|
+
import { DEFAULT_ACCEPTED_FILE_TYPES } from './MessageComposer'
|
|
5
5
|
|
|
6
6
|
const originalMediaRecorder = (globalThis as Record<string, unknown>).MediaRecorder
|
|
7
7
|
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,12 @@ export type {
|
|
|
15
15
|
RichComposerVariable,
|
|
16
16
|
RichMessageComposerHandle,
|
|
17
17
|
} from './RichMessageComposer'
|
|
18
|
+
export {
|
|
19
|
+
AudioRecorderButton,
|
|
20
|
+
DEFAULT_AUDIO_RECORDER_BUTTON_LABELS,
|
|
21
|
+
DEFAULT_MAX_RECORDING_MILLISECONDS,
|
|
22
|
+
} from './AudioRecorderButton'
|
|
23
|
+
export type { AudioRecorderButtonProps, AudioRecorderButtonLabels } from './AudioRecorderButton'
|
|
18
24
|
export { WhatsAppMessageEditor, DEFAULT_WHATSAPP_MESSAGE_EDITOR_LABELS } from './WhatsAppMessageEditor'
|
|
19
25
|
export { SimpleEmojiPicker } from './SimpleEmojiPicker'
|
|
20
26
|
export { DateDivider } from './DateDivider'
|
|
@@ -20,7 +20,7 @@ import { MessageComposer } from '../MessageComposer'
|
|
|
20
20
|
import { DateDivider } from '../DateDivider'
|
|
21
21
|
import { ConversationWallpaper } from '../Wallpaper'
|
|
22
22
|
import type { PreviewWebhookClient, SendPreviewMediaParams } from './createPreviewWebhookClient'
|
|
23
|
-
import { AudioRecorderButton } from '
|
|
23
|
+
import { AudioRecorderButton } from '../AudioRecorderButton'
|
|
24
24
|
|
|
25
25
|
export type ConversationPreviewProps = {
|
|
26
26
|
client: PreviewWebhookClient
|
|
@@ -305,7 +305,7 @@ export function ConversationPreview({
|
|
|
305
305
|
que envia — sem esse aviso o operador grava, não vê nada acontecer e conclui que o
|
|
306
306
|
microfone está quebrado. */
|
|
307
307
|
placeholder={
|
|
308
|
-
isRecording ? 'Gravando… toque no quadrado para
|
|
308
|
+
isRecording ? 'Gravando… toque no quadrado para ouvir' : (placeholder ?? 'Escreva como o cliente…')
|
|
309
309
|
}
|
|
310
310
|
idleAction={
|
|
311
311
|
uploadMedia ? (
|
package/src/preview/index.ts
CHANGED
|
@@ -29,8 +29,8 @@ export { ConversationPreview } from './ConversationPreview'
|
|
|
29
29
|
export { mediaTypeOf } from './ConversationPreview'
|
|
30
30
|
export type { ConversationPreviewProps, PreviewUploadedMedia } from './ConversationPreview'
|
|
31
31
|
|
|
32
|
-
export { AudioRecorderButton, DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from '
|
|
33
|
-
export type { AudioRecorderButtonProps, AudioRecorderButtonLabels } from '
|
|
32
|
+
export { AudioRecorderButton, DEFAULT_AUDIO_RECORDER_BUTTON_LABELS } from '../AudioRecorderButton'
|
|
33
|
+
export type { AudioRecorderButtonProps, AudioRecorderButtonLabels } from '../AudioRecorderButton'
|
|
34
34
|
|
|
35
35
|
export {
|
|
36
36
|
createPreviewWebhookClient,
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Gravação de áudio no simulador, pelo microfone do próprio navegador.
|
|
3
|
-
*
|
|
4
|
-
* Existe porque áudio é o formato que mais chega de cliente real e o que mais quebra fluxo: sem
|
|
5
|
-
* poder gravar aqui, testar o caminho de transcrição exigia mandar mensagem do celular de alguém.
|
|
6
|
-
*
|
|
7
|
-
* O arquivo gravado sai daqui como `File` e segue exatamente o mesmo caminho de um anexo — quem
|
|
8
|
-
* hospeda e devolve o `mediaId` é o host, via `uploadMedia`.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { useCallback, useRef, useState } from 'react'
|
|
12
|
-
|
|
13
|
-
export interface AudioRecorderButtonLabels {
|
|
14
|
-
start: string
|
|
15
|
-
stop: string
|
|
16
|
-
unsupported: string
|
|
17
|
-
denied: string
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export const DEFAULT_AUDIO_RECORDER_BUTTON_LABELS: AudioRecorderButtonLabels = {
|
|
21
|
-
start: 'Gravar áudio',
|
|
22
|
-
stop: 'Parar gravação',
|
|
23
|
-
unsupported: 'Este navegador não grava áudio.',
|
|
24
|
-
denied: 'Sem permissão para usar o microfone.',
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export interface AudioRecorderButtonProps {
|
|
28
|
-
onRecorded: (file: File) => void | Promise<void>
|
|
29
|
-
onFailure?: (message: string) => void
|
|
30
|
-
/**
|
|
31
|
-
* Avisa quando a gravação começa e termina. O botão é um interruptor — o segundo toque é que
|
|
32
|
-
* envia — e sem um aviso fora dele o operador grava, não vê nada acontecer e desiste achando
|
|
33
|
-
* que o microfone está quebrado.
|
|
34
|
-
*/
|
|
35
|
-
onRecordingChange?: (isRecording: boolean) => void
|
|
36
|
-
/**
|
|
37
|
-
* Teto de duração da gravação, em milissegundos. Passado o tempo, o gravador para e envia o que
|
|
38
|
-
* tem. Produto com limite próprio sobrescreve.
|
|
39
|
-
*/
|
|
40
|
-
maxDurationMilliseconds?: number
|
|
41
|
-
labels?: Partial<AudioRecorderButtonLabels>
|
|
42
|
-
disabled?: boolean
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Cinco minutos: com o codec de voz do WhatsApp isso dá menos de 3MB, folgado dentro do teto de
|
|
47
|
-
* 16MB que a Meta impõe a áudio, e é mais do que qualquer recado de cliente. O corte automático
|
|
48
|
-
* existe porque gravação esquecida aberta só se descobre no envio, com o arquivo inteiro perdido.
|
|
49
|
-
*/
|
|
50
|
-
export const DEFAULT_MAX_RECORDING_MILLISECONDS = 5 * 60 * 1000
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Ordem de preferência de formato: os dois primeiros o WhatsApp aceita como áudio; `webm` é só
|
|
54
|
-
* saída de emergência para navegador que não grava mais nada — gravar em webm e descobrir na hora
|
|
55
|
-
* do envio que o formato é inválido é pior do que gravar já no formato certo.
|
|
56
|
-
*/
|
|
57
|
-
const RECORDING_FORMATS = [
|
|
58
|
-
{ mimeType: 'audio/ogg;codecs=opus', uploadMimeType: 'audio/ogg', extension: 'ogg' },
|
|
59
|
-
{ mimeType: 'audio/mp4', uploadMimeType: 'audio/mp4', extension: 'm4a' },
|
|
60
|
-
{ mimeType: 'audio/webm', uploadMimeType: 'audio/webm', extension: 'webm' },
|
|
61
|
-
] as const
|
|
62
|
-
|
|
63
|
-
export type RecordingFormat = (typeof RECORDING_FORMATS)[number]
|
|
64
|
-
|
|
65
|
-
/** Primeiro formato que o navegador sabe gravar, ou `undefined` se não souber gravar nenhum. */
|
|
66
|
-
export function resolveRecordingFormat(): RecordingFormat | undefined {
|
|
67
|
-
if (typeof MediaRecorder === 'undefined') return undefined
|
|
68
|
-
// `isTypeSupported` não existe em toda implementação; onde falta, o primeiro da lista é o palpite.
|
|
69
|
-
if (typeof MediaRecorder.isTypeSupported !== 'function') return RECORDING_FORMATS[0]
|
|
70
|
-
return RECORDING_FORMATS.find((format) => MediaRecorder.isTypeSupported(format.mimeType))
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function AudioRecorderButton({
|
|
74
|
-
onRecorded,
|
|
75
|
-
onFailure,
|
|
76
|
-
onRecordingChange,
|
|
77
|
-
maxDurationMilliseconds = DEFAULT_MAX_RECORDING_MILLISECONDS,
|
|
78
|
-
labels,
|
|
79
|
-
disabled,
|
|
80
|
-
}: AudioRecorderButtonProps) {
|
|
81
|
-
const startLabel = labels?.start ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.start
|
|
82
|
-
const stopLabel = labels?.stop ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.stop
|
|
83
|
-
const [isRecording, setIsRecording] = useState(false)
|
|
84
|
-
const recorderRef = useRef<MediaRecorder | null>(null)
|
|
85
|
-
const autoStopRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
86
|
-
|
|
87
|
-
const stop = useCallback(() => {
|
|
88
|
-
recorderRef.current?.stop()
|
|
89
|
-
}, [])
|
|
90
|
-
|
|
91
|
-
const start = useCallback(async () => {
|
|
92
|
-
const format = resolveRecordingFormat()
|
|
93
|
-
if (!format || !navigator.mediaDevices?.getUserMedia) {
|
|
94
|
-
onFailure?.(labels?.unsupported ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.unsupported)
|
|
95
|
-
return
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
try {
|
|
99
|
-
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
|
100
|
-
const recorder = new MediaRecorder(stream, { mimeType: format.mimeType })
|
|
101
|
-
const chunks: Blob[] = []
|
|
102
|
-
|
|
103
|
-
recorder.addEventListener('dataavailable', (event) => {
|
|
104
|
-
if (event.data.size > 0) chunks.push(event.data)
|
|
105
|
-
})
|
|
106
|
-
recorder.addEventListener('stop', () => {
|
|
107
|
-
// Solta o microfone assim que para: sem isto o indicador de gravação do navegador fica
|
|
108
|
-
// aceso depois do envio, e o operador acha que o simulador continua ouvindo.
|
|
109
|
-
stream.getTracks().forEach((track) => track.stop())
|
|
110
|
-
clearTimeout(autoStopRef.current)
|
|
111
|
-
setIsRecording(false)
|
|
112
|
-
onRecordingChange?.(false)
|
|
113
|
-
recorderRef.current = null
|
|
114
|
-
// O `File` sai com o MIME sem os parâmetros de codec: `audio/ogg;codecs=opus` serve ao
|
|
115
|
-
// gravador, mas quem valida upload compara com `audio/ogg` puro.
|
|
116
|
-
const blob = new Blob(chunks, { type: format.uploadMimeType })
|
|
117
|
-
void onRecorded(
|
|
118
|
-
new File([blob], `audio-${Date.now()}.${format.extension}`, { type: format.uploadMimeType }),
|
|
119
|
-
)
|
|
120
|
-
})
|
|
121
|
-
|
|
122
|
-
recorderRef.current = recorder
|
|
123
|
-
recorder.start()
|
|
124
|
-
autoStopRef.current = setTimeout(() => recorder.stop(), maxDurationMilliseconds)
|
|
125
|
-
setIsRecording(true)
|
|
126
|
-
onRecordingChange?.(true)
|
|
127
|
-
} catch {
|
|
128
|
-
onFailure?.(labels?.denied ?? DEFAULT_AUDIO_RECORDER_BUTTON_LABELS.denied)
|
|
129
|
-
}
|
|
130
|
-
}, [labels?.denied, labels?.unsupported, maxDurationMilliseconds, onFailure, onRecorded, onRecordingChange])
|
|
131
|
-
|
|
132
|
-
return (
|
|
133
|
-
<button
|
|
134
|
-
type="button"
|
|
135
|
-
disabled={disabled}
|
|
136
|
-
onClick={() => (isRecording ? stop() : void start())}
|
|
137
|
-
title={isRecording ? stopLabel : startLabel}
|
|
138
|
-
aria-label={isRecording ? stopLabel : startLabel}
|
|
139
|
-
aria-pressed={isRecording}
|
|
140
|
-
/* Mesma caixa do botão de enviar: o microfone ocupa o lugar dele enquanto o campo está
|
|
141
|
-
vazio, e qualquer diferença de tamanho faz a barra pular a cada letra digitada. */
|
|
142
|
-
className={`flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full transition-colors ${
|
|
143
|
-
isRecording
|
|
144
|
-
? 'animate-pulse bg-red-500 text-white ring-4 ring-red-500/30 hover:bg-red-600'
|
|
145
|
-
: 'text-gray-500 hover:bg-gray-200 dark:hover:bg-gray-700'
|
|
146
|
-
}`}
|
|
147
|
-
>
|
|
148
|
-
{isRecording ? (
|
|
149
|
-
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="6" width="12" height="12" rx="2" /></svg>
|
|
150
|
-
) : (
|
|
151
|
-
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z" /><path d="M19 11a7 7 0 0 1-14 0" /><line x1="12" y1="18" x2="12" y2="22" /></svg>
|
|
152
|
-
)}
|
|
153
|
-
</button>
|
|
154
|
-
)
|
|
155
|
-
}
|