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,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 = [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user