Files
Genarrative/src/components/common/CreativeAudioInputPanel.tsx
T
kdletters b3278739a5 接入原生壳音频文件导出能力
新增 file.exportAudio HostBridge 契约和 H5 facade

移动端通过 Expo 缓存文件与系统分享导出受控音频

桌面端通过 Tauri 保存对话框写入受控音频字节

通用音频输入面板仅对本地音频资产展示宿主导出入口

更新壳能力检查、测试、方案文档和共享决策记录
2026-06-18 05:42:16 +08:00

342 lines
9.5 KiB
TypeScript

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<TAsset extends CreativeAudioAsset> = {
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<TAsset>;
};
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<TAsset extends CreativeAudioAsset>(
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<string>((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<TAsset extends CreativeAudioAsset>({
disabled = false,
title,
defaultLabel,
limitLabel,
asset,
buildRecordedFileName,
onAssetChange,
onError,
readFileAsAsset = readCreativeAudioFileAsAsset,
}: CreativeAudioInputPanelProps<TAsset>) {
const [isRecording, setIsRecording] = useState(false);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
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 (
<PlatformSubpanel
title={
<span className="flex min-w-0 items-center gap-2">
<span>{title}</span>
{limitLabel ? (
<PlatformPillBadge tone="muted" size="xs" className="px-2 py-1">
{limitLabel}
</PlatformPillBadge>
) : null}
</span>
}
titleVariant="strong"
actions={
<div className="flex items-center gap-2">
{canExportHostAudio && exportableAsset ? (
<PlatformActionButton
onClick={() => {
void exportHostAudioAsset().catch((caughtError) => {
onError(
caughtError instanceof Error
? caughtError.message
: '音频导出失败。',
);
});
}}
disabled={disabled}
tone="ghost"
size="xs"
className="min-h-0 gap-1"
title="导出音频"
>
<Download className="h-3.5 w-3.5" />
导出
</PlatformActionButton>
) : null}
{asset ? (
<PlatformActionButton
onClick={() => onAssetChange(null)}
disabled={disabled}
tone="ghost"
size="xs"
className="min-h-0"
>
重置
</PlatformActionButton>
) : null}
</div>
}
bodyClassName="mt-3 flex flex-wrap items-center gap-2"
>
<PlatformActionButton
asChild="label"
tone="secondary"
className={`min-h-10 cursor-pointer gap-2 px-3 ${
disabled ? 'pointer-events-none opacity-55' : ''
}`}
onClick={(event) => {
if (disabled) {
return;
}
if (!canImportHostAudio) {
return;
}
event.preventDefault();
void importHostAudioAsUploadedAsset().catch((caughtError) => {
onError(
caughtError instanceof Error
? caughtError.message
: '音频读取失败。',
);
});
}}
>
<Upload className="h-4 w-4" />
上传
<input
type="file"
accept="audio/*"
disabled={disabled}
className="sr-only"
onChange={(event) => {
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
: '音频读取失败。',
);
});
}}
/>
</PlatformActionButton>
<PlatformActionButton
disabled={disabled}
onClick={() => {
if (isRecording) {
stopRecording();
return;
}
void startRecording();
}}
tone="ghost"
className="min-h-10 gap-2 px-3"
>
{isRecording ? (
<Pause className="h-4 w-4" />
) : (
<Mic className="h-4 w-4" />
)}
{isRecording ? '停止' : '录音'}
</PlatformActionButton>
{asset?.audioSrc ? (
<audio controls src={asset.audioSrc} className="h-10 max-w-full" />
) : (
<div className="text-xs font-bold text-[var(--platform-text-soft)]">
{asset ? '音效已选择' : defaultLabel}
</div>
)}
</PlatformSubpanel>
);
}
export default CreativeAudioInputPanel;