fix(mobile): support Android system file picker
This commit is contained in:
@@ -7,7 +7,7 @@ import type {
|
||||
} from '@/types/api';
|
||||
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
||||
import { getAuth } from './auth';
|
||||
import { fileSelectionFailure } from '../utils/file-selection';
|
||||
import { chooseLocalFile } from './local-file-picker';
|
||||
|
||||
const materialExtensions = ['.pdf', '.doc', '.docx', '.jpg', '.jpeg', '.png', '.webp'];
|
||||
|
||||
@@ -38,28 +38,12 @@ export const getCandidateInterviewRecords = () =>
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
export const chooseCandidateFile = () =>
|
||||
new Promise<CandidateSelectedFile>((resolve, reject) => {
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
type: 'all',
|
||||
extension: materialExtensions,
|
||||
success: (result) => {
|
||||
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
|
||||
const file = (tempFiles[0] || {}) as Partial<CandidateSelectedFile>;
|
||||
const path = file.path || result.tempFilePaths?.[0] || '';
|
||||
if (!path) {
|
||||
reject(new Error('未获取到文件路径'));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
path,
|
||||
name: file.name || path.split('/').pop() || 'candidate-material',
|
||||
size: Number(file.size || 0)
|
||||
});
|
||||
},
|
||||
fail: (error) => reject(fileSelectionFailure(error, '文件'))
|
||||
});
|
||||
export const chooseCandidateFile = (): Promise<CandidateSelectedFile> =>
|
||||
chooseLocalFile({
|
||||
label: '文件',
|
||||
mimeType: '*/*',
|
||||
extensions: materialExtensions,
|
||||
fallbackName: 'candidate-material'
|
||||
});
|
||||
|
||||
export const uploadCandidateMaterial = (file: CandidateSelectedFile, materialType = '补充资料') =>
|
||||
|
||||
@@ -8,32 +8,16 @@ import type {
|
||||
CaseUploadResponse
|
||||
} from '@/types/api';
|
||||
import { apiRequest, apiUrl, authHeaders, authenticatedFileUrl, readTextPayload } from './api';
|
||||
import { fileSelectionFailure } from '../utils/file-selection';
|
||||
import { chooseLocalFile } from './local-file-picker';
|
||||
|
||||
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
|
||||
|
||||
export const chooseCaseAudio = () =>
|
||||
new Promise<CaseSelectedFile>((resolve, reject) => {
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
type: 'all',
|
||||
extension: audioExtensions,
|
||||
success: (result) => {
|
||||
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
|
||||
const file = (tempFiles[0] || {}) as Partial<CaseSelectedFile>;
|
||||
const path = file.path || result.tempFilePaths?.[0] || '';
|
||||
if (!path) {
|
||||
reject(new Error('未获取到文件路径'));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
path,
|
||||
name: file.name || path.split('/').pop() || 'case-audio',
|
||||
size: Number(file.size || 0)
|
||||
});
|
||||
},
|
||||
fail: (error) => reject(fileSelectionFailure(error, '音频'))
|
||||
});
|
||||
export const chooseCaseAudio = (): Promise<CaseSelectedFile> =>
|
||||
chooseLocalFile({
|
||||
label: '音频',
|
||||
mimeType: 'audio/*',
|
||||
extensions: audioExtensions,
|
||||
fallbackName: 'case-audio'
|
||||
});
|
||||
|
||||
export const uploadCaseAudio = (file: CaseSelectedFile) =>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
let activePlatformFileSelections = 0;
|
||||
|
||||
export const isPlatformFileSelectionActive = () => activePlatformFileSelections > 0;
|
||||
|
||||
export const withPlatformFileSelection = async <T>(select: () => Promise<T>): Promise<T> => {
|
||||
activePlatformFileSelections += 1;
|
||||
try {
|
||||
return await select();
|
||||
} finally {
|
||||
activePlatformFileSelections = Math.max(0, activePlatformFileSelections - 1);
|
||||
}
|
||||
};
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
||||
import { getSelectedPosition } from './auth';
|
||||
import { fileSelectionFailure } from '../utils/file-selection';
|
||||
import { withPlatformFileSelection } from './file-selection-lifecycle';
|
||||
import { isAndroidAppRuntime } from './local-file-picker';
|
||||
|
||||
type RawKnowledgeSnippet = KnowledgeSnippet & {
|
||||
title?: string;
|
||||
@@ -20,7 +22,44 @@ type RawKnowledgeSnippet = KnowledgeSnippet & {
|
||||
const mediaExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v'];
|
||||
const videoExtensions = new Set(['.mp4', '.mov', '.avi', '.mkv', '.webm', '.m4v']);
|
||||
|
||||
export const chooseKnowledgeMedia = () =>
|
||||
interface AppMediaFile {
|
||||
tempFilePath?: string;
|
||||
path?: string;
|
||||
size?: number;
|
||||
fileType?: 'image' | 'video';
|
||||
}
|
||||
|
||||
interface AppChooseMediaResult {
|
||||
tempFiles?: AppMediaFile[];
|
||||
}
|
||||
|
||||
type UniWithChooseMedia = {
|
||||
chooseMedia?: (options: {
|
||||
count: number;
|
||||
mediaType: Array<'image' | 'video'>;
|
||||
sourceType: Array<'album'>;
|
||||
success: (result: AppChooseMediaResult) => void;
|
||||
fail: (error: unknown) => void;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
const validateKnowledgeMedia = (
|
||||
path: string,
|
||||
name: string,
|
||||
size: number,
|
||||
declaredKind?: 'image' | 'video'
|
||||
): KnowledgeMediaSelectedFile => {
|
||||
if (!path) throw new Error('未获取到附件文件');
|
||||
const extension = `.${name.split('.').pop()?.toLowerCase() || ''}`;
|
||||
const kind = declaredKind || (videoExtensions.has(extension) ? 'video' : 'image');
|
||||
const maxSize = kind === 'video' ? 100 * 1024 * 1024 : 10 * 1024 * 1024;
|
||||
if (size > maxSize) {
|
||||
throw new Error(kind === 'video' ? '视频不能超过 100MB' : '图片不能超过 10MB');
|
||||
}
|
||||
return { path, name, size, kind };
|
||||
};
|
||||
|
||||
const chooseKnowledgeMediaWithFileInput = () =>
|
||||
new Promise<KnowledgeMediaSelectedFile>((resolve, reject) => {
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
@@ -31,24 +70,49 @@ export const chooseKnowledgeMedia = () =>
|
||||
const file = (tempFiles[0] || {}) as { path?: string; name?: string; size?: number };
|
||||
const path = file.path || result.tempFilePaths?.[0] || '';
|
||||
const name = file.name || path.split('/').pop() || '现场附件';
|
||||
const extension = `.${name.split('.').pop()?.toLowerCase() || ''}`;
|
||||
const kind = videoExtensions.has(extension) ? 'video' : 'image';
|
||||
const size = Number(file.size || 0);
|
||||
const maxSize = kind === 'video' ? 100 * 1024 * 1024 : 10 * 1024 * 1024;
|
||||
if (!path) {
|
||||
reject(new Error('未获取到附件文件'));
|
||||
return;
|
||||
try {
|
||||
resolve(validateKnowledgeMedia(path, name, size));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
if (size > maxSize) {
|
||||
reject(new Error(kind === 'video' ? '视频不能超过 100MB' : '图片不能超过 10MB'));
|
||||
return;
|
||||
}
|
||||
resolve({ path, name, size, kind });
|
||||
},
|
||||
fail: (error) => reject(fileSelectionFailure(error, '附件'))
|
||||
});
|
||||
});
|
||||
|
||||
const chooseKnowledgeMediaWithAppPicker = () =>
|
||||
new Promise<KnowledgeMediaSelectedFile>((resolve, reject) => {
|
||||
const chooseMedia = (uni as unknown as UniWithChooseMedia).chooseMedia;
|
||||
if (!chooseMedia) {
|
||||
reject(new Error('当前 App 版本无法选择图片或视频'));
|
||||
return;
|
||||
}
|
||||
chooseMedia({
|
||||
count: 1,
|
||||
mediaType: ['image', 'video'],
|
||||
sourceType: ['album'],
|
||||
success: (result) => {
|
||||
const file = result.tempFiles?.[0] || {};
|
||||
const path = file.tempFilePath || file.path || '';
|
||||
const name = path.split('/').pop() || (file.fileType === 'video' ? '现场视频.mp4' : '现场图片.jpg');
|
||||
// App chooseMedia reports the selected file size in KiB.
|
||||
const size = Math.max(0, Number(file.size || 0)) * 1024;
|
||||
try {
|
||||
resolve(validateKnowledgeMedia(path, name, size, file.fileType));
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
fail: (error) => reject(fileSelectionFailure(error, '附件'))
|
||||
});
|
||||
});
|
||||
|
||||
export const chooseKnowledgeMedia = () =>
|
||||
isAndroidAppRuntime()
|
||||
? withPlatformFileSelection(chooseKnowledgeMediaWithAppPicker)
|
||||
: chooseKnowledgeMediaWithFileInput();
|
||||
|
||||
const normalizeSnippet = (snippet: RawKnowledgeSnippet): KnowledgeSnippet => ({
|
||||
...snippet,
|
||||
docTitle: snippet.docTitle || snippet.title || '',
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import { FileSelectionCancelledError, fileSelectionFailure } from '../utils/file-selection';
|
||||
import { withPlatformFileSelection } from './file-selection-lifecycle';
|
||||
|
||||
export interface LocalSelectedFile {
|
||||
path: string;
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface LocalFilePickerOptions {
|
||||
label: string;
|
||||
mimeType?: string;
|
||||
extensions?: string[];
|
||||
fallbackName?: string;
|
||||
}
|
||||
|
||||
interface AndroidBridge {
|
||||
importClass: (target: unknown) => any;
|
||||
invoke: (target: unknown, method: string, ...args: unknown[]) => any;
|
||||
runtimeMainActivity: () => any;
|
||||
}
|
||||
|
||||
interface AppPlusRuntime {
|
||||
os?: { name?: string };
|
||||
android?: AndroidBridge;
|
||||
}
|
||||
|
||||
const runtimePlus = () =>
|
||||
(globalThis as typeof globalThis & { plus?: AppPlusRuntime }).plus;
|
||||
|
||||
export const isAndroidAppRuntime = () => {
|
||||
const runtime = runtimePlus();
|
||||
return runtime?.os?.name?.toLowerCase() === 'android' && Boolean(runtime.android);
|
||||
};
|
||||
|
||||
const normalizeExtension = (value: string) => {
|
||||
const extension = value.trim().toLowerCase();
|
||||
if (!extension) return '';
|
||||
return extension.startsWith('.') ? extension : `.${extension}`;
|
||||
};
|
||||
|
||||
const safeFileName = (value: string, fallback: string) => {
|
||||
const normalized = value
|
||||
.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(-120);
|
||||
return normalized || fallback;
|
||||
};
|
||||
|
||||
const selectedExtension = (name: string) => {
|
||||
const index = name.lastIndexOf('.');
|
||||
return index >= 0 ? name.slice(index).toLowerCase() : '';
|
||||
};
|
||||
|
||||
const validateExtension = (name: string, options: LocalFilePickerOptions) => {
|
||||
const accepted = (options.extensions || []).map(normalizeExtension).filter(Boolean);
|
||||
if (!accepted.length || !name) return;
|
||||
if (!accepted.includes(selectedExtension(name))) {
|
||||
throw new Error(`不支持该${options.label}格式`);
|
||||
}
|
||||
};
|
||||
|
||||
const chooseWithUni = (options: LocalFilePickerOptions) =>
|
||||
new Promise<LocalSelectedFile>((resolve, reject) => {
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
type: 'all',
|
||||
extension: options.extensions,
|
||||
success: (result) => {
|
||||
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
|
||||
const file = (tempFiles[0] || {}) as Partial<LocalSelectedFile>;
|
||||
const path = file.path || result.tempFilePaths?.[0] || '';
|
||||
if (!path) {
|
||||
reject(new Error(`未获取到${options.label}文件`));
|
||||
return;
|
||||
}
|
||||
const name = file.name || path.split('/').pop() || options.fallbackName || 'selected-file';
|
||||
try {
|
||||
validateExtension(name, options);
|
||||
resolve({ path, name, size: Number(file.size || 0) });
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
},
|
||||
fail: (error) => reject(fileSelectionFailure(error, options.label))
|
||||
});
|
||||
});
|
||||
|
||||
const readAndroidMetadata = (bridge: AndroidBridge, resolver: any, uri: any) => {
|
||||
let cursor: any = null;
|
||||
try {
|
||||
cursor = resolver.query(uri, null, null, null, null);
|
||||
if (!cursor) return { name: '', size: 0 };
|
||||
bridge.importClass(cursor);
|
||||
if (!cursor.moveToFirst()) return { name: '', size: 0 };
|
||||
const nameIndex = Number(cursor.getColumnIndex('_display_name'));
|
||||
const sizeIndex = Number(cursor.getColumnIndex('_size'));
|
||||
return {
|
||||
name: nameIndex >= 0 ? String(cursor.getString(nameIndex) || '') : '',
|
||||
size: sizeIndex >= 0 ? Number(cursor.getLong(sizeIndex) || 0) : 0
|
||||
};
|
||||
} catch {
|
||||
return { name: '', size: 0 };
|
||||
} finally {
|
||||
try {
|
||||
cursor?.close();
|
||||
} catch {
|
||||
// Metadata is optional; stream copying below remains authoritative.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const copyAndroidUriToCache = (
|
||||
bridge: AndroidBridge,
|
||||
main: any,
|
||||
uri: any,
|
||||
options: LocalFilePickerOptions
|
||||
): LocalSelectedFile => {
|
||||
const resolver = main.getContentResolver();
|
||||
bridge.importClass(resolver);
|
||||
const metadata = readAndroidMetadata(bridge, resolver, uri);
|
||||
if (metadata.name) validateExtension(metadata.name, options);
|
||||
|
||||
const File = bridge.importClass('java.io.File');
|
||||
const FileInputStream = bridge.importClass('java.io.FileInputStream');
|
||||
const FileOutputStream = bridge.importClass('java.io.FileOutputStream');
|
||||
// uni.uploadFile on App reliably consumes app-scoped external cache paths.
|
||||
// Files under /data/user/0/... can be opened by this activity but may be
|
||||
// rejected or truncated by DCloud's native multipart uploader.
|
||||
const cacheRoot = main.getExternalCacheDir() || main.getCacheDir();
|
||||
bridge.importClass(cacheRoot);
|
||||
const pickerDirectory = new File(cacheRoot, 'aihr-picker');
|
||||
pickerDirectory.mkdirs();
|
||||
|
||||
const fallbackName = options.fallbackName || `selected-${options.label}`;
|
||||
const name = safeFileName(metadata.name, fallbackName);
|
||||
const target = new File(pickerDirectory, `${Date.now()}-${name}`);
|
||||
const descriptor = resolver.openFileDescriptor(uri, 'r');
|
||||
if (!descriptor) throw new Error(`无法读取所选${options.label}`);
|
||||
|
||||
bridge.importClass(descriptor);
|
||||
const fileDescriptor = descriptor.getFileDescriptor();
|
||||
bridge.importClass(fileDescriptor);
|
||||
const input = new FileInputStream(fileDescriptor);
|
||||
const output = new FileOutputStream(target);
|
||||
bridge.importClass(input);
|
||||
bridge.importClass(output);
|
||||
const sourceChannel = input.getChannel();
|
||||
const targetChannel = output.getChannel();
|
||||
bridge.importClass(sourceChannel);
|
||||
bridge.importClass(targetChannel);
|
||||
let copied = 0;
|
||||
try {
|
||||
while (true) {
|
||||
// Native.js cannot reliably marshal Java byte[] arguments. FileChannel
|
||||
// keeps the SAF content copy entirely on the Java side.
|
||||
const count = Number(bridge.invoke(
|
||||
targetChannel,
|
||||
'transferFrom',
|
||||
sourceChannel,
|
||||
copied,
|
||||
16 * 1024 * 1024
|
||||
));
|
||||
if (!Number.isFinite(count) || count <= 0) break;
|
||||
copied += count;
|
||||
}
|
||||
output.flush();
|
||||
} catch {
|
||||
try {
|
||||
target.delete();
|
||||
} catch {
|
||||
// The cache directory is private and may be cleaned by the system.
|
||||
}
|
||||
throw new Error(`无法读取所选${options.label}`);
|
||||
} finally {
|
||||
try {
|
||||
input.close();
|
||||
} catch {
|
||||
// Best-effort native stream cleanup.
|
||||
}
|
||||
try {
|
||||
output.close();
|
||||
} catch {
|
||||
// Best-effort native stream cleanup.
|
||||
}
|
||||
try {
|
||||
descriptor.close();
|
||||
} catch {
|
||||
// Best-effort descriptor cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
const path = String(target.getAbsolutePath() || '');
|
||||
if (!path || copied <= 0) {
|
||||
try {
|
||||
target.delete();
|
||||
} catch {
|
||||
// The empty cache file is harmless if the platform already removed it.
|
||||
}
|
||||
throw new Error(`未获取到${options.label}文件`);
|
||||
}
|
||||
return {
|
||||
path,
|
||||
name,
|
||||
size: copied || metadata.size
|
||||
};
|
||||
};
|
||||
|
||||
const chooseWithAndroidDocumentPicker = (options: LocalFilePickerOptions) =>
|
||||
new Promise<LocalSelectedFile>((resolve, reject) => {
|
||||
const runtime = runtimePlus();
|
||||
const bridge = runtime?.android;
|
||||
if (!bridge) {
|
||||
reject(new Error(`无法选择${options.label},请重试`));
|
||||
return;
|
||||
}
|
||||
|
||||
const main = bridge.runtimeMainActivity();
|
||||
const Intent = bridge.importClass('android.content.Intent');
|
||||
const Activity = bridge.importClass('android.app.Activity');
|
||||
const requestCode = 0x4a17;
|
||||
const previousHandler = main.onActivityResult;
|
||||
let settled = false;
|
||||
const restoreHandler = () => {
|
||||
if (main.onActivityResult === onActivityResult) {
|
||||
main.onActivityResult = previousHandler || null;
|
||||
}
|
||||
};
|
||||
const finish = (callback: () => void) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
restoreHandler();
|
||||
callback();
|
||||
};
|
||||
const onActivityResult = (incomingCode: number, resultCode: number, data: any) => {
|
||||
if (incomingCode !== requestCode) {
|
||||
if (typeof previousHandler === 'function') previousHandler(incomingCode, resultCode, data);
|
||||
return;
|
||||
}
|
||||
if (resultCode !== Activity.RESULT_OK || !data) {
|
||||
finish(() => reject(new FileSelectionCancelledError(options.label)));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
bridge.importClass(data);
|
||||
const uri = data.getData();
|
||||
if (!uri) {
|
||||
finish(() => reject(new FileSelectionCancelledError(options.label)));
|
||||
return;
|
||||
}
|
||||
bridge.importClass(uri);
|
||||
const selected = copyAndroidUriToCache(bridge, main, uri, options);
|
||||
finish(() => resolve(selected));
|
||||
} catch (error) {
|
||||
finish(() => reject(error instanceof Error
|
||||
? error
|
||||
: new Error(`无法选择${options.label},请重试`)));
|
||||
}
|
||||
};
|
||||
|
||||
main.onActivityResult = onActivityResult;
|
||||
try {
|
||||
const intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType(options.mimeType || '*/*');
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
main.startActivityForResult(intent, requestCode);
|
||||
} catch (error) {
|
||||
finish(() => reject(fileSelectionFailure(error, options.label)));
|
||||
}
|
||||
});
|
||||
|
||||
export const chooseLocalFile = (options: LocalFilePickerOptions) =>
|
||||
isAndroidAppRuntime()
|
||||
? withPlatformFileSelection(() => chooseWithAndroidDocumentPicker(options))
|
||||
: chooseWithUni(options);
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
PersonalSpace,
|
||||
ServiceMemory
|
||||
} from '@/types/api';
|
||||
import { fileSelectionFailure } from '../utils/file-selection';
|
||||
import { chooseLocalFile } from './local-file-picker';
|
||||
import type { CaptureStatusHistory } from '@/types/api';
|
||||
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
||||
import { personalItemApiPath, personalSessionApiPath } from './personal-assistant-id';
|
||||
@@ -140,14 +140,8 @@ export const createUrlItem = (data: { url: string; title?: string; capturedAt?:
|
||||
apiRequest<PersonalItemCreated>({ url: '/api/aihr/personal-assistant/items/url', method: 'POST', data, timeout: 45000 });
|
||||
|
||||
export interface PersonalSelectedFile { path: string; name: string; size: number; }
|
||||
export const choosePersonalFile = () => new Promise<PersonalSelectedFile>((resolve, reject) => {
|
||||
uni.chooseFile({ count: 1, type: 'all', success: (result) => {
|
||||
const file = (Array.isArray(result.tempFiles) ? result.tempFiles[0] : undefined) as Partial<PersonalSelectedFile> | undefined;
|
||||
const path = file?.path || result.tempFilePaths?.[0] || '';
|
||||
if (!path) return reject(new Error('未获取到文件路径'));
|
||||
resolve({ path, name: file?.name || path.split('/').pop() || 'personal-file', size: Number(file?.size || 0) });
|
||||
}, fail: (error) => reject(fileSelectionFailure(error, '文件')) });
|
||||
});
|
||||
export const choosePersonalFile = (): Promise<PersonalSelectedFile> =>
|
||||
chooseLocalFile({ label: '文件', mimeType: '*/*', fallbackName: 'personal-file' });
|
||||
|
||||
export const uploadPersonalFile = (file: PersonalSelectedFile, title?: string, capturedAt?: string) =>
|
||||
new Promise<PersonalItemCreated>((resolve, reject) => uni.uploadFile({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SpeechSelectedFile } from '@/types/api';
|
||||
import { microphoneDeniedMessage, requestMicrophonePermission } from '../utils/microphone';
|
||||
|
||||
export type SpeechCapture =
|
||||
| { kind: 'blob'; blob: Blob; filename: string }
|
||||
@@ -163,7 +164,14 @@ const startBrowserCapture = async (handlers: SpeechCaptureHandlers): Promise<Spe
|
||||
* native temporary file; H5 preserves the existing MediaRecorder blob flow.
|
||||
*/
|
||||
export const startSpeechCapture = async (handlers: SpeechCaptureHandlers): Promise<SpeechCaptureSession | null> => {
|
||||
if (isAppRuntime()) return startNativeCapture(handlers);
|
||||
if (isAppRuntime()) {
|
||||
const permission = await requestMicrophonePermission();
|
||||
if (permission === 'denied' || permission === 'denied-always') {
|
||||
handlers.onError?.(new Error(microphoneDeniedMessage(permission)));
|
||||
return null;
|
||||
}
|
||||
return startNativeCapture(handlers);
|
||||
}
|
||||
return startBrowserCapture(handlers);
|
||||
};
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { SpeechCapture } from './speech-capture';
|
||||
import { estimateMpegAudioDuration } from './mpeg-audio-duration';
|
||||
import { dataUriToBlobUrl, decodeDataAudioUri, hasNativeAudioFileSupport, isDataAudioUri, removeNativeTempAudio, writeNativeTempAudio } from './speech-audio-source';
|
||||
import { applyTtsVoicePreference, getTtsDialectPreference, getTtsVoicePreference } from './tts-voice-preference';
|
||||
import { fileSelectionFailure } from '../utils/file-selection';
|
||||
import { chooseLocalFile } from './local-file-picker';
|
||||
|
||||
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
|
||||
|
||||
@@ -26,28 +26,12 @@ export const normalizeOssId = (value: unknown): string | undefined => {
|
||||
return /^\d+$/.test(text) ? text : undefined;
|
||||
};
|
||||
|
||||
export const chooseSpeechAudio = () =>
|
||||
new Promise<SpeechSelectedFile>((resolve, reject) => {
|
||||
uni.chooseFile({
|
||||
count: 1,
|
||||
type: 'all',
|
||||
extension: audioExtensions,
|
||||
success: (result) => {
|
||||
const tempFiles = Array.isArray(result.tempFiles) ? result.tempFiles : [];
|
||||
const file = (tempFiles[0] || {}) as Partial<SpeechSelectedFile>;
|
||||
const path = file.path || result.tempFilePaths?.[0] || '';
|
||||
if (!path) {
|
||||
reject(new Error('未获取到音频文件'));
|
||||
return;
|
||||
}
|
||||
resolve({
|
||||
path,
|
||||
name: file.name || path.split('/').pop() || 'practice-audio',
|
||||
size: Number(file.size || 0)
|
||||
});
|
||||
},
|
||||
fail: (error) => reject(fileSelectionFailure(error, '音频'))
|
||||
});
|
||||
export const chooseSpeechAudio = (): Promise<SpeechSelectedFile> =>
|
||||
chooseLocalFile({
|
||||
label: '音频',
|
||||
mimeType: 'audio/*',
|
||||
extensions: audioExtensions,
|
||||
fallbackName: 'practice-audio'
|
||||
});
|
||||
|
||||
export type SpeechAbortRegistrar = (abort: (() => void) | null) => void;
|
||||
|
||||
Reference in New Issue
Block a user