b3278739a5
新增 file.exportAudio HostBridge 契约和 H5 facade 移动端通过 Expo 缓存文件与系统分享导出受控音频 桌面端通过 Tauri 保存对话框写入受控音频字节 通用音频输入面板仅对本地音频资产展示宿主导出入口 更新壳能力检查、测试、方案文档和共享决策记录
342 lines
9.5 KiB
TypeScript
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;
|