feat:项目打包成Electron
This commit is contained in:
@@ -6,6 +6,18 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
// Electron API 类型
|
||||
interface ElectronAPI {
|
||||
requestCameraAccess: () => Promise<boolean>;
|
||||
isElectron: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI?: ElectronAPI;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CameraManagerHandle {
|
||||
/** 获取当前视频轨道 */
|
||||
stream: MediaStream | null;
|
||||
@@ -13,39 +25,130 @@ export interface CameraManagerHandle {
|
||||
captureFrame: () => string | null;
|
||||
}
|
||||
|
||||
/** 等待 video 元素接收到第一帧数据 */
|
||||
function waitForVideoReady(video: HTMLVideoElement, timeoutMs = 10000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (video.readyState >= 2 && video.videoWidth > 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error(`视频加载超时 (readyState=${video.readyState}, paused=${video.paused}, readyState=${video.readyState})`));
|
||||
}, timeoutMs);
|
||||
|
||||
const onLoadedData = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
|
||||
const onPlaying = () => {
|
||||
if (video.readyState >= 2 && video.videoWidth > 0) {
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
|
||||
function cleanup() {
|
||||
clearTimeout(timer);
|
||||
video.removeEventListener("loadeddata", onLoadedData);
|
||||
video.removeEventListener("playing", onPlaying);
|
||||
}
|
||||
|
||||
video.addEventListener("loadeddata", onLoadedData);
|
||||
video.addEventListener("playing", onPlaying);
|
||||
|
||||
// 主动触发播放(autoPlay 在某些环境下不生效)
|
||||
video.play().catch(() => { /* play 可能在设置 srcObject 期间被中断,重试即可 */ });
|
||||
});
|
||||
}
|
||||
|
||||
export function useCamera() {
|
||||
const { t } = useI18n();
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// 防止 React StrictMode 或快速连续调用导致并发执行
|
||||
const isStartingRef = useRef(false);
|
||||
|
||||
/** 停止当前的摄像头流 */
|
||||
const stopCurrentStream = useCallback(() => {
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = null;
|
||||
}
|
||||
setStream(null);
|
||||
}, []);
|
||||
|
||||
const startCamera = useCallback(async () => {
|
||||
// 防止并发调用
|
||||
if (isStartingRef.current) return;
|
||||
isStartingRef.current = true;
|
||||
|
||||
try {
|
||||
// 清理之前的流(防止 "play interrupted by new load")
|
||||
stopCurrentStream();
|
||||
|
||||
// Electron 环境:先通过 IPC 请求系统级权限(触发 macOS TCC 弹窗)
|
||||
if (window.electronAPI?.isElectron) {
|
||||
const granted = await window.electronAPI.requestCameraAccess();
|
||||
if (!granted) {
|
||||
setError(t("error.cameraAccess"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
video: { facingMode: "environment", width: 640, height: 480 },
|
||||
audio: false,
|
||||
});
|
||||
|
||||
streamRef.current = mediaStream;
|
||||
setStream(mediaStream);
|
||||
if (videoRef.current) {
|
||||
videoRef.current.srcObject = mediaStream;
|
||||
|
||||
const video = videoRef.current;
|
||||
if (video) {
|
||||
video.srcObject = mediaStream;
|
||||
|
||||
try {
|
||||
await waitForVideoReady(video);
|
||||
} catch (err) {
|
||||
// play() 可能被新的 load 中断,重试一次
|
||||
console.warn("[Camera] 首次等待视频就绪失败,重试:", (err as Error).message);
|
||||
try {
|
||||
video.play().catch(() => {});
|
||||
await waitForVideoReady(video, 5000);
|
||||
} catch {
|
||||
// macOS TCC 可能阻止了视频帧,但仍保留流(用户可手动授权后重试)
|
||||
console.warn("[Camera] 视频元素未能加载帧数据(可能 macOS 摄像头权限未授予)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : t("error.cameraAccess");
|
||||
setError(message);
|
||||
console.error("[Camera] 获取摄像头失败:", err);
|
||||
} finally {
|
||||
isStartingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
}, [t, stopCurrentStream]);
|
||||
|
||||
const stopCamera = useCallback(() => {
|
||||
stream?.getTracks().forEach((track) => track.stop());
|
||||
setStream(null);
|
||||
}, [stream]);
|
||||
stopCurrentStream();
|
||||
isStartingRef.current = false;
|
||||
}, [stopCurrentStream]);
|
||||
|
||||
/** 从 video 元素捕获当前帧为 JPEG DataURL */
|
||||
const captureFrame = useCallback((): string | null => {
|
||||
const video = videoRef.current;
|
||||
if (!video || video.readyState < 2) return null;
|
||||
if (!video || video.readyState < 2 || video.videoWidth === 0) return null;
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = video.videoWidth;
|
||||
|
||||
@@ -6,6 +6,18 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
|
||||
// Electron API 类型
|
||||
interface ElectronAPI {
|
||||
requestMicAccess: () => Promise<boolean>;
|
||||
isElectron: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI?: ElectronAPI;
|
||||
}
|
||||
}
|
||||
|
||||
export function useMicrophone() {
|
||||
const { t } = useI18n();
|
||||
const [stream, setStream] = useState<MediaStream | null>(null);
|
||||
@@ -14,6 +26,17 @@ export function useMicrophone() {
|
||||
|
||||
const startMic = useCallback(async () => {
|
||||
try {
|
||||
// Electron 环境:先通过 IPC 请求系统级权限
|
||||
if (window.electronAPI?.isElectron) {
|
||||
const granted = await window.electronAPI.requestMicAccess();
|
||||
if (!granted) {
|
||||
const msg = t("error.micAccess");
|
||||
setError(msg);
|
||||
console.warn("[Mic] macOS 系统麦克风权限被拒绝");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
sampleRate: 16000,
|
||||
|
||||
@@ -6,10 +6,40 @@
|
||||
|
||||
import type { ClientMessage, ServerMessage, WsMessage } from "../types";
|
||||
|
||||
// WebSocket 地址:优先使用环境变量,否则基于当前页面地址自动推导
|
||||
const WS_URL =
|
||||
import.meta.env.VITE_WS_URL ||
|
||||
`${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}/ws`;
|
||||
// Electron 类型声明(通过 preload 脚本注入)
|
||||
interface ElectronAPI {
|
||||
getBackendUrl: () => string;
|
||||
isElectron: boolean;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI?: ElectronAPI;
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket 地址优先级:
|
||||
// 1. Electron preload 注入的地址(桌面端)
|
||||
// 2. Vite 环境变量
|
||||
// 3. 根据当前页面地址自动推导(浏览器端)
|
||||
function getWsUrl(): string {
|
||||
// Electron 环境:使用 preload 注入的地址
|
||||
if (window.electronAPI?.isElectron) {
|
||||
return window.electronAPI.getBackendUrl();
|
||||
}
|
||||
|
||||
// Vite 环境变量
|
||||
if (import.meta.env.VITE_WS_URL) {
|
||||
return import.meta.env.VITE_WS_URL;
|
||||
}
|
||||
|
||||
// 浏览器端:根据当前页面地址推导
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const host = window.location.host || "localhost:8080";
|
||||
return `${protocol}//${host}/ws`;
|
||||
}
|
||||
|
||||
const WS_URL = getWsUrl();
|
||||
const PING_INTERVAL = 30_000; // 30 秒心跳
|
||||
const MAX_RECONNECT_DELAY = 30_000; // 最大重连延迟 30 秒
|
||||
|
||||
|
||||
Reference in New Issue
Block a user