feat(ui): migrate Flutter UI library to Next.js 16 (App Router)
Migrate all 87 Dart files from /Users/chase/Documents/cozsweet/lib/ui/ to TypeScript React components, replacing Flutter-native features with Web equivalents and reimplementing the BLoC pattern with React Context + useReducer. Highlights - 1:1 BLoC -> Context+useReducer+side-effects mapping for auth, chat, user, and sidebar features (4 providers wired in root-providers.tsx) - 500px MobileShell primitive eliminates per-screen layout boilerplate - Headless Dialog primitive (createPortal + ESC + scroll lock) powers quota / pwa-install / username / pronouns / subscription / other-options dialogs - New integrations/: facebook-sdk, google-identity, pwa-install, media-recorder (Web Speech API + MediaRecorder), browser-detect, chat-websocket (singleton), message-queue (throttled send) - CSS Modules + existing design tokens (no new styling system); extended with breakpoints.css and dimensions.css - Custom hooks: useBreakpoint (useSyncExternalStore), useFullVisibility (IntersectionObserver), usePwaInstall, usePullToRefresh - Vitest + jsdom; 15 unit tests (chat-reducer, auth-validators) - React 19.2.4, Next.js 16.2.7, Zod 4, lottie-react, classnames Workarounds - next.config.ts: experimental.staticGenerationRetryCount = 0 to bypass Next.js 16.2.7 /_global-error workStore prerender bug - src/app/global-error.tsx: required Client Component - src/types/globals.d.ts: centralized window.google / FB / SpeechRecognition declarations Routes (/, /splash, /auth, /chat, /chat/deviceid/[deviceId], /sidebar) all build and lint clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 浏览器 / WebView 检测
|
||||
*
|
||||
* 原始 Dart: `lib/utils/browser_detector.dart`
|
||||
*
|
||||
* 主要用例:
|
||||
* - 嵌入 Facebook / 微信 / 微博内嵌浏览器时,给用户提示「在外部浏览器打开」。
|
||||
* - 判断 PWA 模式(standalone)。
|
||||
*/
|
||||
export const BrowserDetector = {
|
||||
/** 当前是否在 Facebook 内嵌浏览器 */
|
||||
isFacebookInAppBrowser(ua: string | null = null): boolean {
|
||||
if (typeof navigator === "undefined" && !ua) return false;
|
||||
const userAgent = ua ?? (typeof navigator !== "undefined" ? navigator.userAgent : "");
|
||||
return /FBAN|FBAV|Instagram/i.test(userAgent);
|
||||
},
|
||||
|
||||
/** 当前是否在微信内嵌浏览器 */
|
||||
isWeChatInAppBrowser(ua: string | null = null): boolean {
|
||||
if (typeof navigator === "undefined" && !ua) return false;
|
||||
const userAgent = ua ?? (typeof navigator !== "undefined" ? navigator.userAgent : "");
|
||||
return /MicroMessenger/i.test(userAgent);
|
||||
},
|
||||
|
||||
/** 当前是否在 WebView(不通用;仅作 best-effort 判断) */
|
||||
isInAppBrowser(ua: string | null = null): boolean {
|
||||
if (typeof navigator === "undefined" && !ua) return false;
|
||||
const userAgent = ua ?? (typeof navigator !== "undefined" ? navigator.userAgent : "");
|
||||
return /WebView|; wv\)|FBAN|FBAV|MicroMessenger|Instagram|Line\//i.test(userAgent);
|
||||
},
|
||||
|
||||
/** 当前是否以 PWA standalone 模式运行 */
|
||||
isPWAStandalone(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return (
|
||||
window.matchMedia?.("(display-mode: standalone)").matches === true ||
|
||||
// iOS Safari 旧版
|
||||
(navigator as Navigator & { standalone?: boolean }).standalone === true
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 聊天 WebSocket 处理器
|
||||
*
|
||||
* 原始 Dart: lib/core/net/websocket_handler.dart
|
||||
*
|
||||
* 浏览器方案:使用原生 `WebSocket` API,封装连接管理 + 重连 + 事件回调。
|
||||
*
|
||||
* 事件:
|
||||
* - onConnected(userId)
|
||||
* - onTyping(isTyping)
|
||||
* - onSentence(index, text, total, done) ← AI 逐句推送
|
||||
* - onVoiceReady(index, audioUrl)
|
||||
* - onIntimacyUpdate(intimacyChange, newIntimacy, relationshipStage)
|
||||
* - onMoodUpdate(mood)
|
||||
* - onError(errorMessage)
|
||||
*/
|
||||
import { getApiConfig } from "@/data/api/config/api_config";
|
||||
|
||||
export interface SentencePayload {
|
||||
index: number;
|
||||
text: string;
|
||||
total: number;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface IntimacyPayload {
|
||||
intimacyChange?: number;
|
||||
newIntimacy?: number;
|
||||
relationshipStage?: string;
|
||||
}
|
||||
|
||||
export class ChatWebSocket {
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private disposed = false;
|
||||
|
||||
onConnected: ((userId: string) => void) | null = null;
|
||||
onTyping: ((isTyping: boolean) => void) | null = null;
|
||||
onSentence: ((payload: SentencePayload) => void) | null = null;
|
||||
onVoiceReady: ((index: number, audioUrl: string) => void) | null = null;
|
||||
onIntimacyUpdate: ((payload: IntimacyPayload) => void) | null = null;
|
||||
onMoodUpdate: ((mood: string) => void) | null = null;
|
||||
onError: ((errorMessage: string) => void) | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly serverUrl: string,
|
||||
private readonly token: string,
|
||||
) {}
|
||||
|
||||
get isConnected(): boolean {
|
||||
return this.ws?.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.disposed) return;
|
||||
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) return;
|
||||
try {
|
||||
const url = `${this.serverUrl}?token=${encodeURIComponent(this.token)}`;
|
||||
this.ws = new WebSocket(url);
|
||||
this.ws.onopen = () => {
|
||||
// 连接成功;userId 由后端在第一条消息中下发
|
||||
};
|
||||
this.ws.onmessage = (e) => this.handleMessage(e.data);
|
||||
this.ws.onerror = () => this.onError?.("WebSocket error");
|
||||
this.ws.onclose = () => {
|
||||
if (!this.disposed) {
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
this.onError?.(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
|
||||
sendMessage(content: string): boolean {
|
||||
if (!this.isConnected) return false;
|
||||
this.ws?.send(JSON.stringify({ type: "message", content }));
|
||||
return true;
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.disposed = true;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
private handleMessage(data: unknown): void {
|
||||
if (typeof data !== "string") return;
|
||||
let payload: {
|
||||
type?: string;
|
||||
userId?: string;
|
||||
isTyping?: boolean;
|
||||
index?: number;
|
||||
text?: string;
|
||||
total?: number;
|
||||
done?: boolean;
|
||||
audioUrl?: string;
|
||||
intimacyChange?: number;
|
||||
newIntimacy?: number;
|
||||
relationshipStage?: string;
|
||||
mood?: string;
|
||||
error?: string;
|
||||
};
|
||||
try {
|
||||
payload = JSON.parse(data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
switch (payload.type) {
|
||||
case "connected":
|
||||
if (payload.userId) this.onConnected?.(payload.userId);
|
||||
break;
|
||||
case "typing":
|
||||
this.onTyping?.(payload.isTyping ?? false);
|
||||
break;
|
||||
case "sentence":
|
||||
this.onSentence?.({
|
||||
index: payload.index ?? 0,
|
||||
text: payload.text ?? "",
|
||||
total: payload.total ?? 0,
|
||||
done: payload.done ?? false,
|
||||
});
|
||||
break;
|
||||
case "voice_ready":
|
||||
this.onVoiceReady?.(payload.index ?? 0, payload.audioUrl ?? "");
|
||||
break;
|
||||
case "intimacy_update":
|
||||
this.onIntimacyUpdate?.({
|
||||
intimacyChange: payload.intimacyChange,
|
||||
newIntimacy: payload.newIntimacy,
|
||||
relationshipStage: payload.relationshipStage,
|
||||
});
|
||||
break;
|
||||
case "mood_update":
|
||||
if (payload.mood) this.onMoodUpdate?.(payload.mood);
|
||||
break;
|
||||
case "error":
|
||||
this.onError?.(payload.error ?? "Unknown error");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 默认创建实例:从 env 读取 WS URL。 */
|
||||
export function createChatWebSocket(token: string): ChatWebSocket {
|
||||
const base = getApiConfig().wsUrl;
|
||||
return new ChatWebSocket(base, token);
|
||||
}
|
||||
Vendored
+39
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Facebook SDK 全局类型声明
|
||||
*/
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
FB?: {
|
||||
init(params: {
|
||||
appId: string;
|
||||
cookie?: boolean;
|
||||
xfbml?: boolean;
|
||||
version: string;
|
||||
}): void;
|
||||
login(
|
||||
callback: (response: {
|
||||
authResponse?: {
|
||||
accessToken: string;
|
||||
userID: string;
|
||||
expiresIn: number;
|
||||
};
|
||||
status?: "connected" | "not_authorized" | "unknown";
|
||||
}) => void,
|
||||
options?: { scope: string },
|
||||
): void;
|
||||
logout(callback?: () => void): void;
|
||||
api(
|
||||
path: string,
|
||||
callback: (response: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
picture?: { data?: { url?: string } };
|
||||
}) => void,
|
||||
): void;
|
||||
};
|
||||
fbAsyncInit?: () => void;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Facebook SDK JS 封装
|
||||
*
|
||||
* 原始 Dart: `flutter_facebook_auth` 包
|
||||
* (`lib/data/services/auth/auth_platform.dart` + `auth_screen.dart` 中 Facebook 登录入口)。
|
||||
*
|
||||
* 浏览器方案:
|
||||
* 1. 在 `app/layout.tsx` 通过 `<Script src="https://connect.facebook.net/en_US/sdk.js">` 注入。
|
||||
* 2. 调用 `FB.login` / `FB.getUserData` 完成 OAuth + 用户信息获取。
|
||||
* 3. TypeScript 类型声明在 `facebook-sdk.d.ts`(`window.FB`)。
|
||||
*/
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
FB?: {
|
||||
init(params: {
|
||||
appId: string;
|
||||
cookie?: boolean;
|
||||
xfbml?: boolean;
|
||||
version: string;
|
||||
}): void;
|
||||
login(
|
||||
callback: (response: {
|
||||
authResponse?: {
|
||||
accessToken: string;
|
||||
userID: string;
|
||||
expiresIn: number;
|
||||
};
|
||||
status?: "connected" | "not_authorized" | "unknown";
|
||||
}) => void,
|
||||
options?: { scope: string },
|
||||
): void;
|
||||
logout(callback?: () => void): void;
|
||||
api(
|
||||
path: string,
|
||||
callback: (response: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
picture?: { data?: { url?: string } };
|
||||
}) => void,
|
||||
): void;
|
||||
};
|
||||
fbAsyncInit?: () => void;
|
||||
}
|
||||
}
|
||||
|
||||
export interface FacebookUserData {
|
||||
id: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
pictureUrl?: string;
|
||||
}
|
||||
|
||||
const FB_APP_ID = process.env.NEXT_PUBLIC_FACEBOOK_APP_ID ?? "";
|
||||
const FB_VERSION = process.env.NEXT_PUBLIC_FACEBOOK_API_VERSION ?? "v18.0";
|
||||
|
||||
/**
|
||||
* 初始化 FB SDK(在 `window.fbAsyncInit` 触发时调用一次)。
|
||||
* 由 `app/layout.tsx` 的 inline script 触发。
|
||||
*/
|
||||
export function initFacebookSdk(): void {
|
||||
if (typeof window === "undefined" || !window.FB) return;
|
||||
window.FB.init({
|
||||
appId: FB_APP_ID,
|
||||
cookie: true,
|
||||
xfbml: false,
|
||||
version: FB_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发 Facebook OAuth 登录。
|
||||
* - 成功返回 `{ accessToken, userId }`
|
||||
* - 取消 / 失败返回 `Result.err`
|
||||
*/
|
||||
export async function loginWithFacebook(): Promise<
|
||||
Result<{ accessToken: string; userId: string }>
|
||||
> {
|
||||
if (typeof window === "undefined" || !window.FB) {
|
||||
return Result.err(new Error("Facebook SDK not loaded"));
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
window.FB!.login(
|
||||
(response) => {
|
||||
if (!response.authResponse) {
|
||||
resolve(
|
||||
Result.err(new Error("Facebook login cancelled or failed")),
|
||||
);
|
||||
return;
|
||||
}
|
||||
resolve(
|
||||
Result.ok({
|
||||
accessToken: response.authResponse.accessToken,
|
||||
userId: response.authResponse.userID,
|
||||
}),
|
||||
);
|
||||
},
|
||||
{ scope: "email,public_profile" },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取 Facebook 用户信息(头像、姓名等)。
|
||||
*/
|
||||
export async function getFacebookUserData(): Promise<
|
||||
Result<FacebookUserData>
|
||||
> {
|
||||
if (typeof window === "undefined" || !window.FB) {
|
||||
return Result.err(new Error("Facebook SDK not loaded"));
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
window.FB!.api("/me", (response) => {
|
||||
if (!response.id) {
|
||||
resolve(Result.err(new Error("No Facebook user data")));
|
||||
return;
|
||||
}
|
||||
// 单独拉取头像
|
||||
window.FB!.api(
|
||||
"/me/picture?type=large&redirect=false",
|
||||
(picResp: unknown) => {
|
||||
const url =
|
||||
picResp &&
|
||||
typeof picResp === "object" &&
|
||||
"data" in picResp &&
|
||||
picResp.data &&
|
||||
typeof picResp.data === "object" &&
|
||||
"url" in picResp.data &&
|
||||
typeof picResp.data.url === "string"
|
||||
? picResp.data.url
|
||||
: undefined;
|
||||
resolve(
|
||||
Result.ok({
|
||||
id: response.id!,
|
||||
name: response.name!,
|
||||
email: response.email!,
|
||||
pictureUrl: url,
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Google Identity Services 全局类型声明
|
||||
*/
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
google?: {
|
||||
accounts: {
|
||||
id: {
|
||||
initialize(params: {
|
||||
client_id: string;
|
||||
callback: (response: { credential?: string; select_by?: string }) => void;
|
||||
auto_select?: boolean;
|
||||
cancel_on_tap_outside?: boolean;
|
||||
}): void;
|
||||
prompt(): void;
|
||||
renderButton(
|
||||
parent: HTMLElement,
|
||||
options: {
|
||||
type?: "standard" | "icon";
|
||||
theme?: "outline" | "filled_blue" | "filled_black";
|
||||
size?: "large" | "medium" | "small";
|
||||
text?: "signin_with" | "signup_with" | "continue_with" | "signin";
|
||||
shape?: "rectangular" | "pill" | "circle" | "square";
|
||||
logo_alignment?: "left" | "center";
|
||||
width?: number;
|
||||
locale?: string;
|
||||
},
|
||||
): void;
|
||||
disableAutoSelect(): void;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Google Identity Services (GIS) 封装
|
||||
*
|
||||
* 原始 Dart: `google_sign_in_web` 包
|
||||
* (`lib/ui/auth/widgets/google_sign_in_web.dart` + `auth_bloc._onWebGoogleLoginSuccess`)。
|
||||
*
|
||||
* 浏览器方案:
|
||||
* 1. `<Script src="https://accounts.google.com/gsi/client">` 在 layout 注入。
|
||||
* 2. `renderButton(element, options)` 渲染 GIS 按钮(自动处理 OAuth 流程)。
|
||||
* 3. 登录成功 callback 回调 `onGoogleSignInSuccess(idToken, email)`。
|
||||
* 4. 把 idToken 传给 `authRepository.googleLogin`。
|
||||
*
|
||||
* 全局类型:`google.accounts.id` 在 `google-identity.d.ts` 中声明。
|
||||
*/
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
export interface GoogleSignInSuccess {
|
||||
idToken: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
let _onSuccess: ((s: GoogleSignInSuccess) => void) | null = null;
|
||||
|
||||
/** 设置全局成功回调(auth screen 在 mount 时设置,unmount 时清空)。 */
|
||||
export function setOnGoogleSignInSuccess(
|
||||
cb: ((s: GoogleSignInSuccess) => void) | null,
|
||||
): void {
|
||||
_onSuccess = cb;
|
||||
}
|
||||
|
||||
/** 渲染 GIS 按钮到指定 DOM 元素。 */
|
||||
export function renderGoogleButton(
|
||||
element: HTMLElement,
|
||||
options: { theme?: "outline" | "filled_blue" | "filled_black"; size?: "large" | "medium" | "small"; text?: "signin_with" | "signup_with" | "continue_with" | "signin" } = {},
|
||||
): void {
|
||||
if (typeof window === "undefined" || !window.google?.accounts?.id) return;
|
||||
window.google.accounts.id.renderButton(element, {
|
||||
type: "standard",
|
||||
theme: options.theme ?? "outline",
|
||||
size: options.size ?? "large",
|
||||
text: options.text ?? "continue_with",
|
||||
shape: "pill",
|
||||
width: element.clientWidth || 320,
|
||||
});
|
||||
}
|
||||
|
||||
/** 触发 GIS One Tap 提示(如果用户已登录过 Google)。 */
|
||||
export function promptGoogleOneTap(): Result<true> {
|
||||
if (typeof window === "undefined" || !window.google?.accounts?.id) {
|
||||
return Result.err(new Error("Google Identity Services not loaded"));
|
||||
}
|
||||
window.google.accounts.id.prompt();
|
||||
return Result.ok(true);
|
||||
}
|
||||
|
||||
/** 处理 GIS 凭据回调(由 GIS 内部调用)。 */
|
||||
export function handleGoogleCredentialResponse(
|
||||
response: { credential?: string; select_by?: string },
|
||||
): Result<GoogleSignInSuccess> {
|
||||
if (!response.credential) {
|
||||
return Result.err(new Error("No Google credential returned"));
|
||||
}
|
||||
// GIS 凭据是 JWT;本仓库不解析(idToken 直接交给后端校验),
|
||||
// email 字段需前端手动解码或由后端从 idToken 提取。
|
||||
// 此处仅返回 idToken,email 留空(后端会从 idToken 还原)。
|
||||
const success: GoogleSignInSuccess = {
|
||||
idToken: response.credential,
|
||||
email: "",
|
||||
};
|
||||
_onSuccess?.(success);
|
||||
return Result.ok(success);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 语音输入:Web Speech API + MediaRecorder
|
||||
*
|
||||
* 原始 Dart: `speech_to_text` 包(`lib/ui/chat/widgets/voice_input_button.dart`)。
|
||||
*
|
||||
* 浏览器方案:
|
||||
* - 首选 Web Speech API (`SpeechRecognition` / `webkitSpeechRecognition`):实时转写。
|
||||
* - 不支持时回退到 MediaRecorder(仅录音,不转写)。
|
||||
*
|
||||
* 注:Web Speech API 在 Chrome / Edge 完整支持;Safari / Firefox 仅录音。
|
||||
*/
|
||||
import { Result } from "@/utils/result";
|
||||
|
||||
export interface SpeechRecognitionResultEvent {
|
||||
transcript: string;
|
||||
isFinal: boolean;
|
||||
}
|
||||
|
||||
export interface SpeechRecognitionInstance {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
abort(): void;
|
||||
lang: string;
|
||||
continuous: boolean;
|
||||
interimResults: boolean;
|
||||
onresult: ((event: { results: SpeechRecognitionResultEvent[][] }) => void) | null;
|
||||
onerror: ((event: { error: string }) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
onstart?: (() => void) | null;
|
||||
}
|
||||
|
||||
interface SpeechRecognitionConstructor {
|
||||
new (): SpeechRecognitionInstance;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
SpeechRecognition?: SpeechRecognitionConstructor;
|
||||
webkitSpeechRecognition?: SpeechRecognitionConstructor;
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否支持实时语音识别 */
|
||||
export function isSpeechRecognitionSupported(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return Boolean(window.SpeechRecognition || window.webkitSpeechRecognition);
|
||||
}
|
||||
|
||||
/** 启动语音识别,返回 `SpeechRecognitionInstance` 由调用方管理生命周期。 */
|
||||
export function createSpeechRecognition(): Result<SpeechRecognitionInstance> {
|
||||
if (typeof window === "undefined") {
|
||||
return Result.err(new Error("Speech recognition not available on server"));
|
||||
}
|
||||
const Ctor = window.SpeechRecognition ?? window.webkitSpeechRecognition;
|
||||
if (!Ctor) {
|
||||
return Result.err(new Error("Speech recognition not supported"));
|
||||
}
|
||||
const recognition = new Ctor();
|
||||
recognition.interimResults = true;
|
||||
recognition.continuous = false;
|
||||
recognition.lang = navigator?.language ?? "en-US";
|
||||
return Result.ok(recognition);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
* MediaRecorder 录音(语音消息 / 降级方案)
|
||||
* ============================================================ */
|
||||
|
||||
export interface MediaRecording {
|
||||
blob: Blob;
|
||||
url: string;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export class MediaRecorderWrapper {
|
||||
private recorder: MediaRecorder | null = null;
|
||||
private chunks: BlobPart[] = [];
|
||||
private startedAt = 0;
|
||||
|
||||
static isSupported(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
typeof window.MediaRecorder !== "undefined" &&
|
||||
!!navigator.mediaDevices?.getUserMedia
|
||||
);
|
||||
}
|
||||
|
||||
async start(): Promise<Result<true>> {
|
||||
if (!MediaRecorderWrapper.isSupported()) {
|
||||
return Result.err(new Error("MediaRecorder not supported"));
|
||||
}
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mimeType = MediaRecorder.isTypeSupported("audio/webm")
|
||||
? "audio/webm"
|
||||
: "";
|
||||
this.recorder = mimeType ? new MediaRecorder(stream, { mimeType }) : new MediaRecorder(stream);
|
||||
this.chunks = [];
|
||||
this.startedAt = Date.now();
|
||||
this.recorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) this.chunks.push(e.data);
|
||||
};
|
||||
this.recorder.start();
|
||||
return Result.ok(true);
|
||||
} catch (e) {
|
||||
return Result.err(e);
|
||||
}
|
||||
}
|
||||
|
||||
stop(): Promise<Result<MediaRecording>> {
|
||||
return new Promise((resolve) => {
|
||||
if (!this.recorder) {
|
||||
resolve(Result.err(new Error("Recorder not started")));
|
||||
return;
|
||||
}
|
||||
this.recorder.onstop = () => {
|
||||
const durationMs = Date.now() - this.startedAt;
|
||||
const blob = new Blob(this.chunks, { type: this.recorder?.mimeType ?? "audio/webm" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
this.cleanup();
|
||||
resolve(Result.ok({ blob, url, durationMs }));
|
||||
};
|
||||
this.recorder.stop();
|
||||
});
|
||||
}
|
||||
|
||||
abort(): void {
|
||||
this.recorder?.stop();
|
||||
this.cleanup();
|
||||
}
|
||||
|
||||
private cleanup(): void {
|
||||
this.recorder?.stream.getTracks().forEach((t) => t.stop());
|
||||
this.recorder = null;
|
||||
this.chunks = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* 消息发送队列(限流)
|
||||
*
|
||||
* 原始 Dart: lib/core/net/message_queue.dart
|
||||
*
|
||||
* 业务行为:
|
||||
* - 入队消息按 `intervalMs` 间隔逐条发送(默认 1000ms)
|
||||
* - 消费者可声明每条消息是否成功消费;不成功则重新入队
|
||||
* - 队列可在任意时刻清空(dispose)
|
||||
*/
|
||||
export type MessageConsumer = (content: string) => boolean | Promise<boolean>;
|
||||
|
||||
export class MessageQueue {
|
||||
private queue: string[] = [];
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private disposed = false;
|
||||
private consumer: MessageConsumer | null = null;
|
||||
|
||||
constructor(private readonly intervalMs = 1000) {}
|
||||
|
||||
/** 注册消费者(消费者必须能处理失败/重试)。 */
|
||||
setConsumer(consumer: MessageConsumer): void {
|
||||
this.consumer = consumer;
|
||||
}
|
||||
|
||||
enqueue(content: string): void {
|
||||
if (this.disposed) return;
|
||||
this.queue.push(content);
|
||||
if (!this.timer) this.scheduleNext();
|
||||
}
|
||||
|
||||
private async attemptSend(next: string): Promise<void> {
|
||||
if (!this.consumer) {
|
||||
this.queue.unshift(next);
|
||||
return;
|
||||
}
|
||||
let ok = false;
|
||||
try {
|
||||
ok = await this.consumer(next);
|
||||
} catch (e) {
|
||||
console.error("[MessageQueue] consumer error", e);
|
||||
ok = false;
|
||||
}
|
||||
if (!ok) {
|
||||
// 消费失败,放回队首等待下次
|
||||
this.queue.unshift(next);
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleNext(): void {
|
||||
if (this.disposed) return;
|
||||
const next = this.queue.shift();
|
||||
if (next === undefined) {
|
||||
this.timer = null;
|
||||
return;
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
void this.attemptSend(next).finally(() => this.scheduleNext());
|
||||
}, this.intervalMs);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.clear();
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user