import { Download, Mic, Pause, Upload } from 'lucide-react'; import { useRef, useState } from 'react'; import { canUseNativeHostCapability, exportHostAudioFile, type HostFileImportAudioResult, importHostAudioFile, } from '../../services/host-bridge/hostBridge'; import { type CreativeAudioAsset, readCreativeAudioFileAsAsset, } from './creativeAudioFileAsset'; import { PlatformActionButton } from './PlatformActionButton'; import { PlatformPillBadge } from './PlatformPillBadge'; import { PlatformSubpanel } from './PlatformSubpanel'; type CreativeAudioInputPanelProps = { disabled?: boolean; title: string; defaultLabel: string; limitLabel?: string; asset: TAsset | null; buildRecordedFileName: () => string; onAssetChange: (asset: TAsset | null) => void; onError: (message: string | null) => void; readFileAsAsset?: ( file: File, source: 'uploaded' | 'recorded', ) => Promise; }; type ExportableCreativeAudioAsset = CreativeAudioAsset & { blob?: Blob; fileName?: string; mimeType?: string; }; const HOST_EXPORT_AUDIO_MIME_TYPES = new Set([ 'audio/mpeg', 'audio/mp4', 'audio/wav', 'audio/ogg', 'audio/webm', ]); function resolveExportableAudioAsset( asset: TAsset | null, ) { const candidate = asset as ExportableCreativeAudioAsset | null; if ( !candidate?.blob || !(candidate.blob instanceof Blob) || !candidate.fileName?.trim() || !candidate.mimeType?.trim() || !HOST_EXPORT_AUDIO_MIME_TYPES.has(candidate.mimeType) ) { return null; } return { blob: candidate.blob, fileName: candidate.fileName.trim(), mimeType: candidate.mimeType as | 'audio/mpeg' | 'audio/mp4' | 'audio/wav' | 'audio/ogg' | 'audio/webm', }; } function blobToBase64Data(blob: Blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onerror = () => reject(new Error('音频导出失败。')); reader.onload = () => { if (typeof reader.result !== 'string') { reject(new Error('音频导出失败。')); return; } const base64Data = reader.result.split(',')[1] ?? ''; if (!base64Data) { reject(new Error('音频导出失败。')); return; } resolve(base64Data); }; reader.readAsDataURL(blob); }); } export function CreativeAudioInputPanel({ disabled = false, title, defaultLabel, limitLabel, asset, buildRecordedFileName, onAssetChange, onError, readFileAsAsset = readCreativeAudioFileAsAsset, }: CreativeAudioInputPanelProps) { const [isRecording, setIsRecording] = useState(false); const recorderRef = useRef(null); const chunksRef = useRef([]); const canImportHostAudio = canUseNativeHostCapability('file.importAudio'); const canExportHostAudio = canUseNativeHostCapability('file.exportAudio'); const exportableAsset = resolveExportableAudioAsset(asset); const hostAudioImportResultToFile = (result: HostFileImportAudioResult) => { const binary = atob(result.base64Data); const bytes = new Uint8Array(binary.length); for (let index = 0; index < binary.length; index += 1) { bytes[index] = binary.charCodeAt(index); } return new File([bytes], result.fileName, { type: result.mimeType, }); }; const importHostAudioAsUploadedAsset = async () => { const result = await importHostAudioFile(); if (!result) { return; } const file = hostAudioImportResultToFile(result); const nextAsset = await readFileAsAsset(file, 'uploaded'); onError(null); onAssetChange(nextAsset); }; const exportHostAudioAsset = async () => { if (!exportableAsset) { return; } const base64Data = await blobToBase64Data(exportableAsset.blob); const exported = await exportHostAudioFile({ fileName: exportableAsset.fileName, base64Data, mimeType: exportableAsset.mimeType, }); if (exported) { onError(null); } }; const startRecording = async () => { if (disabled || isRecording) { return; } try { if ( typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined' ) { throw new Error('当前浏览器不支持录音。'); } const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const recorder = new MediaRecorder(stream); chunksRef.current = []; recorder.ondataavailable = (event) => { if (event.data.size > 0) { chunksRef.current.push(event.data); } }; recorder.onstop = () => { const blob = new Blob(chunksRef.current, { type: recorder.mimeType || 'audio/webm', }); stream.getTracks().forEach((track) => track.stop()); const file = new File([blob], buildRecordedFileName(), { type: blob.type, }); void readFileAsAsset(file, 'recorded') .then(onAssetChange) .catch((caughtError) => { onError( caughtError instanceof Error ? caughtError.message : '录音保存失败。', ); }); }; recorderRef.current = recorder; recorder.start(); setIsRecording(true); onError(null); } catch (caughtError) { onError( caughtError instanceof Error ? caughtError.message : '录音启动失败。', ); } }; const stopRecording = () => { recorderRef.current?.stop(); recorderRef.current = null; setIsRecording(false); }; return ( {title} {limitLabel ? ( {limitLabel} ) : null} } titleVariant="strong" actions={
{canExportHostAudio && exportableAsset ? ( { void exportHostAudioAsset().catch((caughtError) => { onError( caughtError instanceof Error ? caughtError.message : '音频导出失败。', ); }); }} disabled={disabled} tone="ghost" size="xs" className="min-h-0 gap-1" title="导出音频" > 导出 ) : null} {asset ? ( onAssetChange(null)} disabled={disabled} tone="ghost" size="xs" className="min-h-0" > 重置 ) : null}
} bodyClassName="mt-3 flex flex-wrap items-center gap-2" > { if (disabled) { return; } if (!canImportHostAudio) { return; } event.preventDefault(); void importHostAudioAsUploadedAsset().catch((caughtError) => { onError( caughtError instanceof Error ? caughtError.message : '音频读取失败。', ); }); }} > 上传 { const file = event.currentTarget.files?.[0] ?? null; event.currentTarget.value = ''; if (!file) { return; } void readFileAsAsset(file, 'uploaded') .then((nextAsset) => { onError(null); onAssetChange(nextAsset); }) .catch((caughtError) => { onError( caughtError instanceof Error ? caughtError.message : '音频读取失败。', ); }); }} /> { if (isRecording) { stopRecording(); return; } void startRecording(); }} tone="ghost" className="min-h-10 gap-2 px-3" > {isRecording ? ( ) : ( )} {isRecording ? '停止' : '录音'} {asset?.audioSrc ? (
); } export default CreativeAudioInputPanel;