feat(mobile-app): prepare native app packaging
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
import type { ApiPayload } from '@/types/api';
|
||||
import { clearAuth, getAuth, redirectToLoginForAuthLoss } from './auth';
|
||||
import { isNativeAppRuntime } from './speech-capture';
|
||||
|
||||
const apiBase = import.meta.env.VITE_API_BASE || '/dev-api';
|
||||
const configuredApiBase = import.meta.env.VITE_API_BASE;
|
||||
const currentApiBase = () => configuredApiBase
|
||||
|| (isNativeAppRuntime() ? 'https://peilian.njzhmj.top/dev-api' : '/dev-api');
|
||||
|
||||
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
|
||||
@@ -31,7 +34,7 @@ export const isAuthApiError = (error: unknown): boolean =>
|
||||
error instanceof ApiError
|
||||
&& (error.statusCode === 401 || error.statusCode === 403 || error.bizCode === 401 || error.bizCode === 403);
|
||||
|
||||
const normalizeUrl = (url: string) => `${apiBase}${url.startsWith('/') ? url : `/${url}`}`;
|
||||
const normalizeUrl = (url: string) => `${currentApiBase()}${url.startsWith('/') ? url : `/${url}`}`;
|
||||
|
||||
export const apiUrl = normalizeUrl;
|
||||
|
||||
@@ -77,6 +80,8 @@ export const parsePayloadText = (statusCode: number, text: string, clearAuthOnUn
|
||||
export const readTextPayload = <T>(statusCode: number, text: string): T =>
|
||||
readPayload<T>(statusCode, parsePayloadText(statusCode, text));
|
||||
|
||||
const shouldUseXhr = () => !isNativeAppRuntime() && typeof XMLHttpRequest !== 'undefined';
|
||||
|
||||
const requestWithXhr = async <T>(options: RequestOptions): Promise<T> => {
|
||||
const method = options.method || 'GET';
|
||||
const hasBody = method !== 'GET' && options.data !== undefined;
|
||||
@@ -109,7 +114,7 @@ const requestWithXhr = async <T>(options: RequestOptions): Promise<T> => {
|
||||
};
|
||||
|
||||
export const apiRequest = async <T>(options: RequestOptions): Promise<T> => {
|
||||
if (typeof XMLHttpRequest !== 'undefined') {
|
||||
if (shouldUseXhr()) {
|
||||
return requestWithXhr<T>(options);
|
||||
}
|
||||
|
||||
@@ -133,7 +138,7 @@ export const apiRequest = async <T>(options: RequestOptions): Promise<T> => {
|
||||
|
||||
export const authenticatedFileUrl = async (url: string): Promise<string> => {
|
||||
const fullUrl = normalizeUrl(url);
|
||||
if (typeof XMLHttpRequest !== 'undefined' && typeof URL !== 'undefined') {
|
||||
if (shouldUseXhr() && typeof URL !== 'undefined') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('GET', fullUrl);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiRequest } from './api';
|
||||
import { isNativeAppRuntime } from './speech-capture';
|
||||
|
||||
type RealtimeStatus = 'idle' | 'connecting' | 'connected' | 'listening' | 'speaking';
|
||||
|
||||
@@ -122,7 +123,8 @@ export interface RealtimePracticeController {
|
||||
}
|
||||
|
||||
const isSupported = () =>
|
||||
typeof navigator !== 'undefined'
|
||||
!isNativeAppRuntime()
|
||||
&& typeof navigator !== 'undefined'
|
||||
&& Boolean(navigator.mediaDevices?.getUserMedia)
|
||||
&& typeof RTCPeerConnection !== 'undefined';
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { SpeechSelectedFile } from '@/types/api';
|
||||
|
||||
export type SpeechCapture =
|
||||
| { kind: 'blob'; blob: Blob; filename: string }
|
||||
| { kind: 'file'; file: SpeechSelectedFile };
|
||||
|
||||
export interface SpeechCaptureSession {
|
||||
stop: () => void;
|
||||
cancel: () => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
interface AppRecorderResult {
|
||||
tempFilePath?: string;
|
||||
fileSize?: number;
|
||||
}
|
||||
|
||||
interface AppRecorderError {
|
||||
errMsg?: string;
|
||||
}
|
||||
|
||||
interface AppRecorderManager {
|
||||
start: (options?: { duration?: number; sampleRate?: number; format?: 'mp3' }) => void;
|
||||
stop: () => void;
|
||||
onStop: (callback: (result: AppRecorderResult) => void) => void;
|
||||
onError: (callback: (error: AppRecorderError) => void) => void;
|
||||
}
|
||||
|
||||
type UniWithRecorder = typeof uni & {
|
||||
getRecorderManager?: () => AppRecorderManager;
|
||||
};
|
||||
|
||||
interface SpeechCaptureHandlers {
|
||||
onCapture: (capture: SpeechCapture) => void;
|
||||
onError?: (error: Error) => void;
|
||||
}
|
||||
|
||||
const isAppRuntime = () => Boolean((globalThis as typeof globalThis & { plus?: unknown }).plus);
|
||||
const supportsBrowserRecording = () =>
|
||||
typeof navigator !== 'undefined'
|
||||
&& Boolean(navigator.mediaDevices?.getUserMedia)
|
||||
&& typeof MediaRecorder !== 'undefined';
|
||||
|
||||
let activeNativeSession: SpeechCaptureSession | null = null;
|
||||
|
||||
const startNativeCapture = (handlers: SpeechCaptureHandlers): SpeechCaptureSession | null => {
|
||||
const manager = (uni as UniWithRecorder).getRecorderManager?.();
|
||||
if (!manager) return null;
|
||||
|
||||
activeNativeSession?.cancel();
|
||||
let active = true;
|
||||
let discarded = false;
|
||||
const cleanup = () => {
|
||||
active = false;
|
||||
if (activeNativeSession === session) activeNativeSession = null;
|
||||
};
|
||||
const finishWithError = (error: Error) => {
|
||||
if (!active) return;
|
||||
cleanup();
|
||||
if (!discarded) handlers.onError?.(error);
|
||||
};
|
||||
const session: SpeechCaptureSession = {
|
||||
stop: () => {
|
||||
if (!active) return;
|
||||
try {
|
||||
manager.stop();
|
||||
} catch (error) {
|
||||
finishWithError(error instanceof Error ? error : new Error('停止录音失败'));
|
||||
}
|
||||
},
|
||||
cancel: () => {
|
||||
if (!active) return;
|
||||
discarded = true;
|
||||
cleanup();
|
||||
try {
|
||||
manager.stop();
|
||||
} catch {
|
||||
// The capture has already been discarded; there is nothing to surface.
|
||||
}
|
||||
},
|
||||
dispose: () => session.cancel()
|
||||
};
|
||||
|
||||
manager.onStop((result) => {
|
||||
if (!active) return;
|
||||
const path = String(result.tempFilePath || '');
|
||||
cleanup();
|
||||
if (discarded) return;
|
||||
if (!path) {
|
||||
handlers.onError?.(new Error('未获取到录音文件,请改用选择音频'));
|
||||
return;
|
||||
}
|
||||
handlers.onCapture({
|
||||
kind: 'file',
|
||||
file: {
|
||||
path,
|
||||
name: `recording-${Date.now()}.mp3`,
|
||||
size: Number(result.fileSize || 0)
|
||||
}
|
||||
});
|
||||
});
|
||||
manager.onError((error) => finishWithError(new Error(error.errMsg || '录音失败,请检查麦克风权限')));
|
||||
|
||||
try {
|
||||
manager.start({ duration: 600000, sampleRate: 16000, format: 'mp3' });
|
||||
activeNativeSession = session;
|
||||
return session;
|
||||
} catch (error) {
|
||||
finishWithError(error instanceof Error ? error : new Error('录音启动失败'));
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const startBrowserCapture = async (handlers: SpeechCaptureHandlers): Promise<SpeechCaptureSession | null> => {
|
||||
if (!supportsBrowserRecording()) return null;
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const recorder = new MediaRecorder(stream);
|
||||
const chunks: Blob[] = [];
|
||||
let active = true;
|
||||
let discarded = false;
|
||||
const releaseStream = () => stream.getTracks().forEach((track) => track.stop());
|
||||
const session: SpeechCaptureSession = {
|
||||
stop: () => {
|
||||
if (active && recorder.state !== 'inactive') recorder.stop();
|
||||
},
|
||||
cancel: () => {
|
||||
if (!active) return;
|
||||
discarded = true;
|
||||
active = false;
|
||||
if (recorder.state !== 'inactive') recorder.stop();
|
||||
releaseStream();
|
||||
},
|
||||
dispose: () => session.cancel()
|
||||
};
|
||||
|
||||
recorder.ondataavailable = (event) => {
|
||||
if (event.data?.size) chunks.push(event.data);
|
||||
};
|
||||
recorder.onerror = () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
releaseStream();
|
||||
handlers.onError?.(new Error('录音失败,请检查麦克风权限'));
|
||||
};
|
||||
recorder.onstop = () => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
releaseStream();
|
||||
if (discarded) return;
|
||||
const blob = new Blob(chunks, { type: recorder.mimeType || 'audio/webm' });
|
||||
if (!blob.size) {
|
||||
handlers.onError?.(new Error('未录到音频,请改用选择音频'));
|
||||
return;
|
||||
}
|
||||
handlers.onCapture({ kind: 'blob', blob, filename: 'recording.webm' });
|
||||
};
|
||||
recorder.start();
|
||||
return session;
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts a microphone capture on the current platform. App-Plus produces a
|
||||
* native temporary file; H5 preserves the existing MediaRecorder blob flow.
|
||||
*/
|
||||
export const startSpeechCapture = async (handlers: SpeechCaptureHandlers): Promise<SpeechCaptureSession | null> => {
|
||||
if (isAppRuntime()) return startNativeCapture(handlers);
|
||||
return startBrowserCapture(handlers);
|
||||
};
|
||||
|
||||
export const isNativeAppRuntime = isAppRuntime;
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AsrResponse, SpeechPlaybackStatus, SpeechSelectedFile, SpeechVoiceProfile, TtsResponse } from '@/types/api';
|
||||
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
||||
import type { SpeechCapture } from './speech-capture';
|
||||
|
||||
const audioExtensions = ['.mp3', '.wav', '.m4a', '.webm', '.aac', '.ogg'];
|
||||
|
||||
@@ -91,6 +92,14 @@ export const transcribeSpeechBlob = (
|
||||
xhr.send(form);
|
||||
});
|
||||
|
||||
export const transcribeSpeechCapture = (
|
||||
capture: SpeechCapture,
|
||||
filename: string,
|
||||
registerAbort?: SpeechAbortRegistrar
|
||||
) => capture.kind === 'blob'
|
||||
? transcribeSpeechBlob(capture.blob, filename, registerAbort)
|
||||
: transcribeSpeechFile(capture.file, registerAbort);
|
||||
|
||||
export const synthesizeSpeech = (text: string, voiceProfile?: SpeechVoiceProfile) =>
|
||||
apiRequest<TtsResponse>({
|
||||
url: '/api/ai/tts',
|
||||
|
||||
Reference in New Issue
Block a user