diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/.eslintrc.js b/One-to-One-Video/NERtcSample-1to1-Web-React/.eslintrc.js
new file mode 100644
index 0000000..df28d0a
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/.eslintrc.js
@@ -0,0 +1,29 @@
+module.exports = {
+ parser: '@typescript-eslint/parser',
+ extends: [
+ 'eslint:recommended', // ESLint 推荐规则
+ 'plugin:react/recommended',
+ 'plugin:@typescript-eslint/recommended',
+ 'prettier',
+ 'plugin:prettier/recommended', // 继承 Prettier 规则
+ ],
+ plugins: ['react', 'react-hooks', '@typescript-eslint', 'prettier'],
+ env: {
+ browser: true,
+ es6: true,
+ node: false,
+ },
+ settings: {
+ react: {
+ version: 'detect',
+ },
+ },
+ rules: {
+ // 自定义规则(可选)
+ 'react/react-in-jsx-scope': 'off',
+ 'react-hooks/rules-of-hooks': 'error',
+ 'react-hooks/exhaustive-deps': 'warn',
+ 'prettier/prettier': 'error',
+ '@typescript-eslint/explicit-module-boundary-types': 'off',
+ },
+};
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/.gitignore b/One-to-One-Video/NERtcSample-1to1-Web-React/.gitignore
new file mode 100644
index 0000000..265f50c
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/.gitignore
@@ -0,0 +1,26 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+package-lock.json
\ No newline at end of file
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/.prettierrc b/One-to-One-Video/NERtcSample-1to1-Web-React/.prettierrc
new file mode 100644
index 0000000..fb1194e
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/.prettierrc
@@ -0,0 +1,18 @@
+
+{
+ "singleQuote": true,
+ "trailingComma": "all",
+ "printWidth": 100,
+ "semi": true,
+ "tabWidth": 2,
+ "jsxSingleQuote": true,
+ "bracketSameLine": false,
+ "arrowParens": "avoid",
+ "endOfLine": "auto",
+ "overrides": [
+ {
+ "files": ".prettierrc",
+ "options": { "parser": "typescript" }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/CHANGELOG.md b/One-to-One-Video/NERtcSample-1to1-Web-React/CHANGELOG.md
new file mode 100644
index 0000000..19056fe
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/CHANGELOG.md
@@ -0,0 +1,28 @@
+# Changelog
+
+- 体验地址: https://app.yunxin.163.com/webdemo/tender/#/nertcDemoH5
+
+## [1.0.0] - 2025-07-01
+
+### Added
+
+- 完整的点对点音视频通话通话场景(移动端H5)
+- 完整了会前设备检测流程
+ - 系统环境检查
+ - 音频采集检查
+ - 扬声器检查
+ - 视频采集检查
+ - 网络检查检查
+- 基本音视频会控
+ - 布局为大小屏模式(默认对端是大盘)
+ - 支持大小屏切换
+ - 支持小屏滑动
+ - 支持开关麦克风
+ - 支持开关摄像头
+ - 支持前置后置摄像头切换
+- 异常监控
+ - 规避浏览器的音频自动播放限制(通过手动触发)
+ - 支持音视频设备不可用异常监控
+ - 支持音视频设备没有数据流监控
+ - 支持网络异常监控
+ - 监控网络质量变化
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/README.md b/One-to-One-Video/NERtcSample-1to1-Web-React/README.md
new file mode 100644
index 0000000..54385dc
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/README.md
@@ -0,0 +1,49 @@
+# 云信音视频移动端浏览器H5 Demo
+
+## 功能
+
+- H5音视频通话会前检测功能
+- H5音视频实时通话功能(包含各种会控)
+
+## 结构
+
+- src: 源码目录
+ - main.tsx: 入口文件
+ - App.tsx: 根组件,作为所有其他组件的容器
+ - src/pages: 页面目录
+ - src/pages/home: 首页
+ - src/pages/preview: 会前检测页面
+ - src/pages/rtc: 实时通话页面
+ - src/components: 组件目录
+ - src/config: 全局配置
+ - src/constant: 全局定义
+ - src/assets: 静态资源
+ - src/store: 全局变量
+ - src/types: 类型定义
+ - src/hooks: 全局钩子
+ - src/routes: 路由配置
+ - src/features: 功能模块目录
+ - src/utils: 工具库
+- dist: 打包目录
+- package.json: 依赖包
+- tsconfig.json: ts语法编译配置
+- .umirc.ts: 开发环境配置
+
+## 开发流程
+
+### 环境依赖
+
+- 脚手架: umi
+- 环境要求:node v18.x
+- src/config/config.ts: 配置云信的appkey和secret
+
+### 开发环境启动
+
+- npm install (node使用 v18版本)
+- npm run dev
+
+### 打包
+
+- npm run build
+
+### 部署
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/index.html b/One-to-One-Video/NERtcSample-1to1-Web-React/index.html
new file mode 100644
index 0000000..4d7ca78
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ NERTC H5 Demo
+
+
+
+
+
+
\ No newline at end of file
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/package.json b/One-to-One-Video/NERtcSample-1to1-Web-React/package.json
new file mode 100644
index 0000000..ac0d7a3
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/package.json
@@ -0,0 +1,49 @@
+{
+ "name": "nertc-demo-h5",
+ "private": true,
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc -b && vite build",
+ "lint": "eslint .",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@vitejs/plugin-basic-ssl": "^2.0.0",
+ "@vitejs/plugin-react": "^4.4.1",
+ "antd-mobile": "^5.39.0",
+ "consola": "^3.4.2",
+ "import": "^0.0.6",
+ "react": "18.2.0",
+ "react-dom": "18.2.0",
+ "react-router-dom": "^6.30.1",
+ "vconsole": "^3.15.1"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.25.0",
+ "@types/lodash-es": "^4.17.12",
+ "@types/node": "^22.15.21",
+ "@types/react": "18.2.0",
+ "@types/react-copy-to-clipboard": "^5.0.7",
+ "@types/react-dom": "18.2.0",
+ "antd": "^5.25.2",
+ "classnames": "2.3.2",
+ "eslint": "^9.28.0",
+ "eslint-config-prettier": "^10.1.5",
+ "eslint-plugin-prettier": "^5.4.1",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-react-refresh": "^0.4.19",
+ "globals": "^16.0.0",
+ "js-sha1": "^0.7.0",
+ "lodash-es": "^4.17.21",
+ "nertc-web-sdk": "^5.8.20",
+ "path": "^0.12.7",
+ "prettier": "^3.5.3",
+ "qrcode.react": "^4.2.0",
+ "react-copy-to-clipboard": "^5.1.0",
+ "typescript": "~5.8.3",
+ "typescript-eslint": "^8.30.1",
+ "vite": "^6.3.5"
+ }
+}
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/public/favicon.bak.ico b/One-to-One-Video/NERtcSample-1to1-Web-React/public/favicon.bak.ico
new file mode 100644
index 0000000..f41aa60
Binary files /dev/null and b/One-to-One-Video/NERtcSample-1to1-Web-React/public/favicon.bak.ico differ
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/public/favicon.ico b/One-to-One-Video/NERtcSample-1to1-Web-React/public/favicon.ico
new file mode 100644
index 0000000..be8cbcb
Binary files /dev/null and b/One-to-One-Video/NERtcSample-1to1-Web-React/public/favicon.ico differ
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/public/vite.svg b/One-to-One-Video/NERtcSample-1to1-Web-React/public/vite.svg
new file mode 100644
index 0000000..e7b8dfb
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/public/vite.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/.prettierrc b/One-to-One-Video/NERtcSample-1to1-Web-React/src/.prettierrc
new file mode 100644
index 0000000..222861c
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/.prettierrc
@@ -0,0 +1,4 @@
+{
+ "tabWidth": 2,
+ "useTabs": false
+}
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/App.css b/One-to-One-Video/NERtcSample-1to1-Web-React/src/App.css
new file mode 100644
index 0000000..f190b84
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/App.css
@@ -0,0 +1,47 @@
+#root {
+ max-width: 1280px;
+ margin: 0 auto;
+ /* padding: 2rem; */
+ text-align: center;
+}
+
+.App {
+ width: 100vw;
+ height: 100vh;
+}
+
+.logo {
+ height: 6em;
+ padding: 1.5em;
+ will-change: filter;
+ transition: filter 300ms;
+}
+.logo:hover {
+ filter: drop-shadow(0 0 2em #646cffaa);
+}
+.logo.react:hover {
+ filter: drop-shadow(0 0 2em #61dafbaa);
+}
+
+@keyframes logo-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ a:nth-of-type(2) .logo {
+ animation: logo-spin infinite 20s linear;
+ }
+}
+
+.card {
+ padding: 2em;
+}
+
+.read-the-docs {
+ color: #888;
+}
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/App.tsx b/One-to-One-Video/NERtcSample-1to1-Web-React/src/App.tsx
new file mode 100644
index 0000000..22b8459
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/App.tsx
@@ -0,0 +1,25 @@
+import { Outlet } from "react-router-dom";
+import { AppProvider } from "./store/index";
+import { RTCProvider } from "./store/index";
+import Navbar from "./components/nav";
+import Logo from "@/assets/yunxinLogo.png";
+import "./App.css";
+import VConsole from "vconsole";
+
+new VConsole();
+function App() {
+ return (
+
+
+ {" "}
+ {/* 在最外层或适当位置添加 RTCProvider */}
+
+
+ {/* 子路由会渲染在这里 */}
+
+
+
+ );
+}
+
+export default App;
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/camera-closed.png b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/camera-closed.png
new file mode 100644
index 0000000..9741912
Binary files /dev/null and b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/camera-closed.png differ
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/camera-flip.png b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/camera-flip.png
new file mode 100644
index 0000000..6d3fb28
Binary files /dev/null and b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/camera-flip.png differ
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/camera-opned.png b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/camera-opned.png
new file mode 100644
index 0000000..cf24c69
Binary files /dev/null and b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/camera-opned.png differ
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/mute.png b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/mute.png
new file mode 100644
index 0000000..f13313f
Binary files /dev/null and b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/mute.png differ
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/openCamera.png b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/openCamera.png
new file mode 100644
index 0000000..a0bc2b4
Binary files /dev/null and b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/openCamera.png differ
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/over.png b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/over.png
new file mode 100644
index 0000000..51b2898
Binary files /dev/null and b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/over.png differ
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/react.svg b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/react.svg
new file mode 100644
index 0000000..6c87de9
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/relieve-silence.png b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/relieve-silence.png
new file mode 100644
index 0000000..eacf444
Binary files /dev/null and b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/relieve-silence.png differ
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/silence.png b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/silence.png
new file mode 100644
index 0000000..0325fc1
Binary files /dev/null and b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/silence.png differ
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/stopCamera.png b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/stopCamera.png
new file mode 100644
index 0000000..0824564
Binary files /dev/null and b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/stopCamera.png differ
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/unmute.png b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/unmute.png
new file mode 100644
index 0000000..a3448df
Binary files /dev/null and b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/unmute.png differ
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/yunxinLogo.png b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/yunxinLogo.png
new file mode 100644
index 0000000..be8cbcb
Binary files /dev/null and b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/yunxinLogo.png differ
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/yunxinLogo1.png b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/yunxinLogo1.png
new file mode 100644
index 0000000..2018c3b
Binary files /dev/null and b/One-to-One-Video/NERtcSample-1to1-Web-React/src/assets/yunxinLogo1.png differ
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/Pip/index.css b/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/Pip/index.css
new file mode 100644
index 0000000..a92b1e9
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/Pip/index.css
@@ -0,0 +1,9 @@
+.pip {
+ border-radius: 8px;
+ overflow: hidden;
+ z-index: 100;
+ user-select: none;
+ touch-action: none; /* 禁用触摸默认行为 */
+ background: #25252d;
+ border: 1px solid #fff;
+}
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/Pip/index.tsx b/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/Pip/index.tsx
new file mode 100644
index 0000000..ab9fa7b
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/Pip/index.tsx
@@ -0,0 +1,196 @@
+import React, {
+ useState,
+ useRef,
+ useEffect,
+ useCallback,
+ forwardRef,
+ useImperativeHandle,
+} from "react";
+import "./index.css";
+
+type PipVideoProps = {
+ initialPosition?: { x: number; y: number };
+ width?: string; // 如 "25vw"
+ aspectRatio?: string; // 如 "9/16"
+ content?: string;
+ onClick?: React.MouseEventHandler; // 添加 onClick 类型
+ // React 18+ 需要显式声明 children
+ children?: React.ReactNode;
+};
+
+// 定义暴露给父组件的 Ref 类型
+export type PipHandle = {
+ getElement: () => HTMLDivElement | null;
+};
+
+const Pip = forwardRef(
+ (
+ {
+ initialPosition = { x: 10, y: 10 },
+ width = "25vw",
+ aspectRatio = "3/4",
+ content = "等待视频流",
+ children, // 2. 直接从 props 解构 children
+ onClick, // 接收 onClick
+ ...restProps // 接收其他所有 props(如 className、style 等)
+ },
+ ref, // 接收外部传入的 ref
+ ) => {
+ const [position, setPosition] = useState(initialPosition);
+ const [isDragging, setIsDragging] = useState(false);
+ const pipRef = useRef(null);
+ const offset = useRef({ x: 0, y: 0 });
+ const [showContent, setShowContent] = useState(true);
+ const observerRef = useRef();
+
+ // 检测 video 是否存在
+ const checkVideo = useCallback(() => {
+ const hasVideo = !!pipRef.current?.querySelector("video");
+ console.log("检测 video 存在:", hasVideo);
+ setShowContent(!hasVideo);
+ return hasVideo;
+ }, []);
+
+ useEffect(() => {
+ if (!pipRef.current) return;
+
+ // MutationObserver 监听 DOM 变化
+ observerRef.current = new MutationObserver(() => {
+ checkVideo();
+ });
+ observerRef.current.observe(pipRef.current, {
+ childList: true,
+ subtree: true,
+ });
+ // 初始检测
+ checkVideo();
+ return () => {
+ observerRef.current?.disconnect();
+ };
+ }, []);
+
+ // 关键修改:使用 useImperativeHandle 暴露 ref
+ useImperativeHandle(ref, () => ({
+ getElement: () => pipRef.current, // 直接返回内部 DOM 元素
+ }));
+ // 处理鼠标/触摸按下
+ const handleStart = (e: React.MouseEvent | React.TouchEvent) => {
+ setIsDragging(true);
+ const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
+ const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
+
+ if (pipRef.current) {
+ const rect = pipRef.current.getBoundingClientRect();
+ offset.current = {
+ x: clientX - rect.left,
+ y: clientY - rect.top,
+ };
+ }
+ // 阻止触摸滚动
+ // if ('touches' in e) {
+ // e.preventDefault();
+ // }
+ };
+
+ // 处理移动
+ const handleMove = (e: MouseEvent | TouchEvent) => {
+ if (!isDragging || !pipRef.current) return;
+
+ // 获取当前鼠标/触摸位置
+ const clientX = "touches" in e ? e.touches[0].clientX : e.clientX;
+ const clientY = "touches" in e ? e.touches[0].clientY : e.clientY;
+
+ // 计算新位置(基于 right 定位)
+ const newRight = window.innerWidth - clientX - offset.current.x;
+ const newTop = clientY - offset.current.y;
+
+ // 边界检查(防止拖出视窗)
+ const maxRight = window.innerWidth - pipRef.current.offsetWidth;
+ const maxTop = window.innerHeight - pipRef.current.offsetHeight - 100;
+
+ setPosition({
+ x: Math.max(0, Math.min(newRight, maxRight)), // right 值
+ y: Math.max(0, Math.min(newTop, maxTop)), // top 值
+ });
+
+ // 阻止触摸滚动
+ if ("touches" in e) {
+ e.preventDefault();
+ }
+ };
+
+ // 处理释放
+ const handleEnd = () => {
+ setIsDragging(false);
+ };
+
+ // 绑定/解绑全局事件
+ useEffect(() => {
+ if (isDragging) {
+ document.addEventListener("mousemove", handleMove);
+ document.addEventListener("touchmove", handleMove as EventListener);
+ document.addEventListener("mouseup", handleEnd);
+ document.addEventListener("touchend", handleEnd);
+ } else {
+ document.removeEventListener("mousemove", handleMove);
+ document.removeEventListener("touchmove", handleMove as EventListener);
+ document.removeEventListener("mouseup", handleEnd);
+ document.removeEventListener("touchend", handleEnd);
+ }
+
+ return () => {
+ document.removeEventListener("mousemove", handleMove);
+ document.removeEventListener("touchmove", handleMove as EventListener);
+ document.removeEventListener("mouseup", handleEnd);
+ document.removeEventListener("touchend", handleEnd);
+ };
+ }, [isDragging]);
+
+ return (
+
+ {/* 内容区域 - 添加事件处理层 */}
+
+ {children}
+
+ {showContent && content}
+
+ );
+ },
+);
+export default Pip;
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/loading/index.tsx b/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/loading/index.tsx
new file mode 100644
index 0000000..8a898bc
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/loading/index.tsx
@@ -0,0 +1,5 @@
+function Loading() {
+ return Loading...
+}
+
+export default Loading
\ No newline at end of file
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/nav/index.css b/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/nav/index.css
new file mode 100644
index 0000000..c2f9484
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/nav/index.css
@@ -0,0 +1,24 @@
+.nav {
+ width: calc(100% - 40px);
+ height: 44px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 0 20px;
+ border-bottom: 1px solid #ebedf0;
+}
+
+.leftSection {
+ display: flex;
+ align-items: center;
+
+ .yunxinlogo {
+ height: 24px;
+ cursor: pointer;
+ }
+
+ .appName {
+ font-size: 18px;
+ color: #333;
+ }
+}
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/nav/index.tsx b/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/nav/index.tsx
new file mode 100644
index 0000000..080bc78
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/nav/index.tsx
@@ -0,0 +1,35 @@
+import React from "react";
+
+import { Divider } from "antd-mobile";
+import "./index.css";
+interface NavProps {
+ logoSrc?: string;
+ appName?: string;
+ userRole?: string;
+}
+
+const Nav: React.FC = ({ logoSrc, appName, userRole }) => {
+ return (
+
+ );
+};
+
+export default Nav;
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/networkSignal/index.css b/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/networkSignal/index.css
new file mode 100644
index 0000000..b9359b6
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/networkSignal/index.css
@@ -0,0 +1,43 @@
+.network-signal-container {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-family: Arial, sans-serif;
+}
+
+.signal-title {
+ font-size: 14px;
+ color: #666;
+}
+
+.signal-bars {
+ display: flex;
+ align-items: flex-end;
+ gap: 2px;
+ height: 20px;
+}
+
+.signal-bar {
+ width: 4px;
+ border-radius: 2px;
+ transition: all 0.3s ease;
+}
+
+.signal-bar:nth-child(1) {
+ height: 5px;
+}
+.signal-bar:nth-child(2) {
+ height: 8px;
+}
+.signal-bar:nth-child(3) {
+ height: 12px;
+}
+.signal-bar:nth-child(4) {
+ height: 15px;
+}
+
+.signal-text {
+ font-size: 12px;
+ font-weight: bold;
+ margin-left: 4px;
+}
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/networkSignal/index.tsx b/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/networkSignal/index.tsx
new file mode 100644
index 0000000..973a527
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/networkSignal/index.tsx
@@ -0,0 +1,64 @@
+import React, { useMemo } from "react";
+import "./index.css";
+
+// 类型定义
+type SignalLevel = 0 | 1 | 2 | 3;
+
+interface Props {
+ level: SignalLevel;
+}
+
+// 使用memo包裹组件 + 自定义比较函数
+const NetworkSignal: React.FC = React.memo(
+ ({ level }) => {
+ const getColor = (bar: number, level: SignalLevel) => {
+ let color = "#f0f0f0"; // 默认灰色,unkown
+ switch (level) {
+ case 1:
+ color = "#ff4d4f"; // 红色,极差
+ if (bar > 2) {
+ color = "#f0f0f0";
+ }
+ break;
+ case 2:
+ color = "#faad14"; // 黄色,一般
+ if (bar > 3) {
+ color = "#f0f0f0";
+ }
+ break;
+ case 3:
+ color = "#52c41a"; // 绿色,很好
+ break;
+ }
+ return color;
+ };
+
+ // 使用useMemo缓存计算结果
+ const bars = useMemo(() => {
+ //const colors = ["#ff4d4f", "#faad14", "#52c41a"];
+ return [1, 2, 3, 4].map((bar) => ({
+ bar,
+ color: getColor(bar, level),
+ active: bar <= level + 1,
+ }));
+ }, [level]);
+ console.log("NetworkSignal render level: ", level);
+ return (
+
+
+ {bars.map(({ bar, color, active }) => (
+
+ ))}
+
+
+ );
+ },
+ (prev, next) => prev.level === next.level,
+); // 仅level变化时重新渲染
+
+export default NetworkSignal;
+export type { SignalLevel };
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/wechatQrCode/index.css b/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/wechatQrCode/index.css
new file mode 100644
index 0000000..ac295e6
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/wechatQrCode/index.css
@@ -0,0 +1,141 @@
+.qr-code-container {
+ font-family: "Arial", sans-serif;
+ max-width: 100vw;
+ margin: 0 auto;
+ text-align: center;
+ background-color: #f9f9f9;
+ border-radius: 10px;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
+ height: calc(100% - 46px);
+}
+
+.qr-code-container h3 {
+ color: #333;
+ margin: 10px 0;
+}
+
+.qr-code-box {
+ margin-top: 10px auto;
+ padding: 15px;
+ background-color: white;
+ border-radius: 8px;
+ display: inline-block;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.loading {
+ padding: 20px;
+ color: #666;
+}
+
+.url-display {
+ display: flex;
+ margin-top: 10px;
+}
+
+.url-input {
+ flex: 1;
+ padding: 10px;
+ border: 1px solid #ddd;
+ border-radius: 4px 0 0 4px;
+ font-size: 14px;
+ outline: none;
+}
+
+.copy-btn {
+ padding: 10px 15px;
+ background-color: #07c160;
+ color: white;
+ border: none;
+ border-radius: 0 4px 4px 0;
+ cursor: pointer;
+ font-size: 14px;
+ transition: background-color 0.3s;
+}
+
+.copy-btn:hover {
+ background-color: #06ad56;
+}
+
+.action-buttons {
+ margin: 10px 0;
+}
+
+.download-btn {
+ padding: 10px 20px;
+ background-color: #1989fa;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 14px;
+ transition: background-color 0.3s;
+}
+
+.download-btn:hover {
+ background-color: #177ee5;
+}
+
+.customize-section {
+ margin-top: 10px 0;
+ padding: 15px;
+ background-color: #fff;
+ border-radius: 8px;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
+}
+
+.customize-section h3 {
+ margin-top: 0;
+ color: #333;
+}
+
+.custom-controls {
+ display: flex;
+ flex-direction: column;
+ gap: 15px;
+}
+
+.control-group {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+}
+
+.control-group label {
+ width: 60px;
+ text-align: right;
+ color: #555;
+}
+
+.control-group input[type="range"] {
+ width: 150px;
+}
+
+.control-group span {
+ width: 50px;
+ text-align: left;
+ color: #666;
+}
+
+.instructions {
+ margin-top: 10px;
+ padding: 15px;
+ background-color: #f0f7ff;
+ border-radius: 8px;
+ text-align: left;
+}
+
+.instructions h3 {
+ margin-top: 0;
+ color: #333;
+}
+
+.instructions ol {
+ padding-left: 20px;
+ color: #555;
+}
+
+.instructions li {
+ margin-bottom: 8px;
+}
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/wechatQrCode/index.tsx b/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/wechatQrCode/index.tsx
new file mode 100644
index 0000000..be59637
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/components/wechatQrCode/index.tsx
@@ -0,0 +1,132 @@
+import { useState, useEffect } from "react";
+import { QRCodeSVG } from "qrcode.react";
+import { CopyToClipboard } from "react-copy-to-clipboard";
+import "./index.css";
+
+const WechatQrCode = () => {
+ const [currentUrl, setCurrentUrl] = useState("");
+ const [isCopied, setIsCopied] = useState(false);
+ const [size, setSize] = useState(200);
+ const [bgColor, setBgColor] = useState("#ffffff");
+ const [fgColor, setFgColor] = useState("#000000");
+
+ // 获取当前页面URL
+ useEffect(() => {
+ setCurrentUrl(window.location.href);
+ }, []);
+
+ // 重置复制状态
+ useEffect(() => {
+ if (isCopied) {
+ const timer = setTimeout(() => setIsCopied(false), 2000);
+ return () => clearTimeout(timer);
+ }
+ }, [isCopied]);
+
+ // 下载二维码
+ const downloadQRCode = () => {
+ const svg = document.getElementById("qr-code-canvas") as Node;
+ const svgData = new XMLSerializer().serializeToString(svg);
+ const canvas = document.createElement("canvas");
+ const ctx = canvas.getContext("2d") as CanvasRenderingContext2D;
+ const img = new Image();
+
+ img.onload = () => {
+ canvas.width = img.width;
+ canvas.height = img.height;
+ ctx.drawImage(img, 0, 0);
+ const pngFile = canvas.toDataURL("image/png");
+ const downloadLink = document.createElement("a");
+ downloadLink.download = "QRCode";
+ downloadLink.href = pngFile;
+ downloadLink.click();
+ };
+
+ img.src =
+ "data:image/svg+xml;base64," +
+ btoa(unescape(encodeURIComponent(svgData)));
+ };
+
+ return (
+
+
请使用微信扫码访问当前页面
+
+
+ {currentUrl ? (
+
+ ) : (
+
加载中...
+ )}
+
+
+
+
+ setIsCopied(true)}>
+
+
+
+
+
+
+
+
+
+
+
+
使用说明
+
+ - 下载二维码
+ - 打开微信,点击右上角"+"
+ - 选择"扫一扫"功能
+ - 扫描二维码图片即可访问当前页面
+
+
+
+ );
+};
+
+export default WechatQrCode;
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/config/index.tsx b/One-to-One-Video/NERtcSample-1to1-Web-React/src/config/index.tsx
new file mode 100644
index 0000000..10275f9
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/config/index.tsx
@@ -0,0 +1,2 @@
+export const appkey = "";
+export const secret = "";
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/constant/index.ts b/One-to-One-Video/NERtcSample-1to1-Web-React/src/constant/index.ts
new file mode 100644
index 0000000..95588e9
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/constant/index.ts
@@ -0,0 +1,9 @@
+export const NETWORK_STATUS = {
+ 0: 'UNKNOWN',
+ 1: 'EXCELLENT',
+ 2: 'GOOD',
+ 3: 'POOR',
+ 4: 'BAD',
+ 5: 'VERYBAD',
+ 6: 'DOWN'
+}
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/features/browserVisibilityMonitor.tsx b/One-to-One-Video/NERtcSample-1to1-Web-React/src/features/browserVisibilityMonitor.tsx
new file mode 100644
index 0000000..2f379da
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/features/browserVisibilityMonitor.tsx
@@ -0,0 +1,131 @@
+export type VisibilityState = "visible" | "hidden";
+export type VisibilityChangeCallback = (state: VisibilityState) => void;
+export type VisibilityEventType = "change" | "hide" | "show";
+
+class BrowserVisibilityMonitor {
+ private isHidden: boolean;
+ private callbacks: Map>;
+ private isWeChatBrowser: boolean;
+
+ constructor() {
+ this.isHidden = false;
+ this.callbacks = new Map();
+ this.isWeChatBrowser = /MicroMessenger/i.test(navigator.userAgent);
+ this.initEventListeners();
+ }
+
+ private initEventListeners(): void {
+ // 标准 Page Visibility API
+ document.addEventListener("visibilitychange", this.handleVisibilityChange);
+
+ // 备用 blur/focus 事件
+ window.addEventListener("blur", this.handleWindowBlur);
+ window.addEventListener("focus", this.handleWindowFocus);
+
+ // 微信浏览器特殊处理
+ if (this.isWeChatBrowser) {
+ window.addEventListener("pagehide", this.handleWeChatHide);
+ window.addEventListener("pageshow", this.handleWeChatShow);
+ }
+ }
+
+ private handleVisibilityChange = (): void => {
+ this.updateState(document.hidden);
+ };
+
+ private handleWindowBlur = (): void => {
+ if (!this.isHidden) {
+ this.updateState(true);
+ }
+ };
+
+ private handleWindowFocus = (): void => {
+ if (this.isHidden) {
+ this.updateState(false);
+ }
+ };
+
+ private handleWeChatHide = (): void => {
+ this.updateState(true);
+ };
+
+ private handleWeChatShow = (): void => {
+ this.updateState(false);
+ };
+
+ private updateState(hidden: boolean): void {
+ const oldState = this.isHidden;
+ this.isHidden = hidden;
+
+ if (oldState !== hidden) {
+ this.notifyCallbacks();
+ }
+ }
+
+ private notifyCallbacks(): void {
+ const state: VisibilityState = this.isHidden ? "hidden" : "visible";
+
+ // 通用 change 事件
+ this.triggerCallbacks("change", state);
+
+ // 特定 hide/show 事件
+ this.triggerCallbacks(this.isHidden ? "hide" : "show", state);
+ }
+
+ private triggerCallbacks(
+ type: VisibilityEventType,
+ state: VisibilityState,
+ ): void {
+ const callbacks = this.callbacks.get(type);
+ if (callbacks) {
+ callbacks.forEach((callback) => callback(state));
+ }
+ }
+
+ public on(
+ event: VisibilityEventType,
+ callback: VisibilityChangeCallback,
+ ): () => void {
+ if (!this.callbacks.has(event)) {
+ this.callbacks.set(event, new Set());
+ }
+ const callbacks = this.callbacks.get(event)!;
+ callbacks.add(callback);
+
+ return () => {
+ callbacks.delete(callback);
+ };
+ }
+
+ public off(
+ event: VisibilityEventType,
+ callback: VisibilityChangeCallback,
+ ): void {
+ const callbacks = this.callbacks.get(event);
+ if (callbacks) {
+ callbacks.delete(callback);
+ }
+ }
+
+ public getState(): VisibilityState {
+ return this.isHidden ? "hidden" : "visible";
+ }
+
+ public destroy(): void {
+ document.removeEventListener(
+ "visibilitychange",
+ this.handleVisibilityChange,
+ );
+ window.removeEventListener("blur", this.handleWindowBlur);
+ window.removeEventListener("focus", this.handleWindowFocus);
+
+ if (this.isWeChatBrowser) {
+ window.removeEventListener("pagehide", this.handleWeChatHide);
+ window.removeEventListener("pageshow", this.handleWeChatShow);
+ }
+
+ this.callbacks.clear();
+ }
+}
+
+export default BrowserVisibilityMonitor;
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/features/rtcManager.tsx b/One-to-One-Video/NERtcSample-1to1-Web-React/src/features/rtcManager.tsx
new file mode 100644
index 0000000..7f8a9aa
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/features/rtcManager.tsx
@@ -0,0 +1,447 @@
+import * as NERTC from "nertc-web-sdk";
+import type { Client } from "nertc-web-sdk/types/client";
+import type { Stream } from "nertc-web-sdk/types/stream";
+import type { NetStatusItem } from "nertc-web-sdk/types/types";
+import { EventEmitter } from "eventemitter3";
+import { getRandomInt, getAppToken, getSystemInfo } from "@/utils";
+import type { Timer } from "@/types";
+
+interface RtcManagerOptions {
+ appkey: string;
+ secret?: string;
+}
+
+interface joinOptions {
+ channelName: string;
+ uid?: string | number;
+ customData?: string;
+ token?: string;
+}
+
+class RTCManager extends EventEmitter {
+ public appkey: string = "";
+ public secret: string | undefined = undefined;
+ public channelName: string = "";
+ public uid: string | number = 0;
+
+ private client: Client | null = null;
+ private localStream: Stream | null = null;
+ private remoteStream: Stream | null = null;
+ //本产品仅仅支持一个远端流,针对为点对点通话场景(这里仅仅是记录一下,并不会订阅或者渲染)
+ private remoteStreamMap = new Map();
+
+ private localVideoView: HTMLElement | null = null;
+ private remoteVideoView: HTMLElement | null = null;
+
+ private mediaTimer: Timer | null = null;
+
+ constructor(options: RtcManagerOptions) {
+ super();
+ this.appkey = options.appkey;
+ this.secret = options.secret;
+ this.initClient();
+ }
+
+ //暴露localStream,方便直接调用sdk的接口API
+ get nertLocalStream() {
+ return this.localStream;
+ }
+
+ //暴露client,方便直接调用sdk的接口API
+ get nertClient() {
+ return this.client;
+ }
+
+ get localView() {
+ return this.localVideoView;
+ }
+
+ async getCameras() {
+ return NERTC.getCameras();
+ }
+
+ interval() {
+ if (this.mediaTimer) {
+ clearInterval(this.mediaTimer);
+ }
+ this.mediaTimer = setInterval(async () => {
+ const audioStats = await this.client?.getLocalAudioStats();
+ const videoStats = await this.client?.getLocalVideoStats("video");
+ // console.log('audioStats: ', audioStats);
+ // console.log('videoStats: ', videoStats);
+ this.emit("media-info", {
+ audioLevel: this.localStream?.getAudioLevel() || 0,
+ audioBitrate: audioStats[0]?.SendBitrate || 0,
+ videoBitrate: videoStats[0]?.SendBitrate || 0,
+ videoFrameRate: videoStats[0]?.SendFrameRate || 0,
+ videoFrameWidth: videoStats[0]?.SendResolutionWidth || 0,
+ videoFrameHeight: videoStats[0]?.SendResolutionHeight || 0,
+ });
+ }, 1000);
+ }
+
+ initClient() {
+ this.client = NERTC.createClient({
+ appkey: this.appkey,
+ debug: true,
+ });
+ //开启日志上传
+ NERTC.Logger.enableLogUpload();
+ this.initRtcEvents();
+ }
+
+ async joinChannel(options: joinOptions) {
+ let { channelName, uid, token, customData = "" } = options;
+ console.log("joinChannel() options: ", JSON.stringify(options, null, " "));
+ if (!channelName) {
+ console.error("没有提供channelName, 无法加入房间");
+ throw new Error("channelName is required");
+ }
+ if (!uid) {
+ //自定义一个随机数
+ uid = getRandomInt(100, 9999);
+ }
+ this.uid = uid as string | number;
+ this.channelName = channelName;
+ if (!token) {
+ if (this.secret) {
+ token = await getAppToken({
+ appkey: this.appkey,
+ secret: this.secret,
+ uid: uid.toString(),
+ channelName,
+ });
+ } else {
+ console.error("没有提供secret 和 token,无法加入房间");
+ throw new Error("secret or secret is required");
+ }
+ }
+ const { isMobile } = getSystemInfo();
+ if (isMobile) {
+ console.log("移动端推荐使用VP8软编");
+ this.client?.setCodecType("VP8");
+ }
+
+ return this.client?.join({
+ channelName,
+ uid: this.uid,
+ token,
+ customData: customData,
+ });
+ }
+
+ async initLocalStream(config?: {
+ audio: boolean;
+ video: boolean;
+ microphoneId: string;
+ cameraId: string;
+ }) {
+ this.localStream = NERTC.createStream({
+ video: config ? config.video : true,
+ audio: config ? config.audio : true,
+ microphoneId: config ? config.microphoneId : "",
+ cameraId: config ? config.cameraId : "",
+ }) as Stream;
+
+ //音频默认即可
+ this.localStream.setAudioProfile("speech_low_quality");
+ //主流建议使用720p/30fps
+ const videoProfile = {
+ resolution: NERTC.VIDEO_QUALITY.VIDEO_QUALITY_720p,
+ frameRate: NERTC.VIDEO_FRAME_RATE.CHAT_VIDEO_FRAME_RATE_30,
+ };
+ this.localStream.setVideoProfile(videoProfile);
+ return this.localStream.init();
+ }
+
+ async playLocalStream(view: HTMLElement) {
+ console.log("playLocalStream() view: ", view);
+ if (!view) {
+ throw new Error("playLocalStream(): view is required");
+ }
+ if (!this.localStream) {
+ throw new Error("Local stream is not initialized");
+ }
+ try {
+ this.localVideoView = view;
+ await this.localStream.play(view, { video: true, muted: true });
+ this.localStream.setLocalRenderMode({
+ width: view.clientWidth - 2, // 减去边框宽度
+ height: view.clientHeight - 2,
+ cut: false,
+ });
+ this.interval();
+ } catch (error) {
+ console.error("playLocalStream error: ", (error as Error).message);
+ throw error;
+ }
+ }
+
+ async publish() {
+ if (!this.localStream) {
+ throw new Error("Local stream is not initialized");
+ }
+ return this.client?.publish(this.localStream);
+ }
+
+ //订阅远端流
+ subscribeStream(stream: Stream) {
+ console.log("subscribeStream() stream", stream);
+ const subscribeConfig = {
+ video: true,
+ audio: true,
+ };
+ stream.setSubscribeConfig(subscribeConfig);
+ this.client?.subscribe(stream).then(() => {
+ console.log("subscribe success");
+ });
+ }
+
+ //取消订阅远端流
+ unsubscribeStream(stream: Stream) {
+ console.log("unsubscribeStream() stream: ", stream);
+ const unsubscribeConfig = {
+ video: true,
+ audio: false,
+ screen: true,
+ };
+ return this.client?.unsubscribe(stream, unsubscribeConfig);
+ }
+
+ async playRemoteAudioStream(uid?: string | number) {
+ console.log("playRemoteAudioStream() uid: ", uid);
+
+ if (!this.remoteStream) {
+ throw new Error("remoteStream is not initialized");
+ }
+ try {
+ // 在UI窗口上显示静音图标,用户手动点击图标后在播放音频
+ await this.remoteStream?.play(this.remoteVideoView as HTMLElement, {
+ video: false,
+ audio: true,
+ muted: false,
+ });
+ } catch (error) {
+ console.error("playRemoteAudioStream error: ", (error as Error).message);
+ throw error;
+ }
+ }
+
+ async playRemoteVideoStream(view: HTMLElement, uid?: string | number) {
+ console.log("playRemoteVideoStream() view: ", view, uid);
+ if (!view) {
+ throw new Error("playRemoteVideoStream(): view is required");
+ }
+ if (!this.remoteStream) {
+ throw new Error("remoteStream is not initialized");
+ }
+ try {
+ this.remoteVideoView = view;
+ //将音频mute后进行播放,保证规避浏览器的自动播发逻辑受限的限制,
+ // 在UI窗口上显示静音图标,用户手动点击图标后在播放音频
+ await this.remoteStream?.play(view, {
+ video: true,
+ audio: false,
+ muted: true,
+ });
+ this.remoteStream.setRemoteRenderMode({
+ width: view.clientWidth - 2, // 减去边框宽度
+ height: view.clientHeight - 2,
+ cut: false,
+ });
+ } catch (error) {
+ console.error("playRemoteVideoStream error: ", (error as Error).message);
+ throw error;
+ }
+ }
+
+ resumeRemoteStream() {
+ console.log("resumeRemoteStream()");
+ if (this.remoteStream) {
+ this.remoteStream?.resume();
+ }
+ }
+
+ swapVideos() {
+ //大小屏幕切换
+ console.log("swapVideos() 大小屏幕切换");
+ if (this.localVideoView && this.remoteVideoView) {
+ } else {
+ //console.warn('swapVideos() localVideoView or localVideoView is not initialized');
+ return false;
+ }
+ try {
+ this.localStream?.stop("video");
+ this.remoteStream?.stop("video");
+ const localVideoView = this.localVideoView;
+ const remoteVideoView = this.remoteVideoView;
+ this.playLocalStream(remoteVideoView as HTMLElement);
+ this.playRemoteVideoStream(localVideoView as HTMLElement);
+ return true;
+ } catch (error) {
+ console.error("swapVideos error: ", (error as Error).message);
+ return false;
+ }
+ }
+
+ getChannelInfo() {
+ return this.client?.getChannelInfo();
+ }
+
+ leaveChannel() {
+ if (this.mediaTimer) {
+ clearInterval(this.mediaTimer);
+ this.mediaTimer = null;
+ }
+ this.client?.leave();
+ this.localStream?.destroy();
+ this.client?.destroy();
+ }
+
+ //RTC事件监听
+ initRtcEvents() {
+ this.initMediaDeviceEvents();
+ this.client?.on("peer-online", (evt) => {
+ console.warn("[NERTC通知]: 收到新用户加入房间的通知: ", evt.uid);
+ this.emit("peer-online", {
+ uid: evt.uid.toString(),
+ });
+ });
+
+ this.client?.on("peer-leave", (evt) => {
+ //@ts-ignore
+ const { uid, reason } = evt;
+ console.warn(
+ "[NERTC通知]: 收到用户离开房间的通知: ",
+ uid,
+ "reason: ",
+ reason,
+ );
+ //删除对应的stream
+ this.remoteStreamMap.delete(uid.toString());
+ if (this.remoteStream?.getId()?.toString() === uid.toString()) {
+ this.remoteStream = null;
+ }
+
+ this.emit("peer-leave", {
+ uid: evt.uid.toString(),
+ reason,
+ });
+ });
+ this.client?.on("stream-added", (evt) => {
+ const remoteStream = evt.stream;
+ console.log(
+ "[NERTC通知]: 收到别人的发布消息: ",
+ remoteStream.getId(),
+ "mediaType: ",
+ evt.mediaType,
+ );
+ if (!this.remoteStream) {
+ this.remoteStream = remoteStream;
+ this.initSreamEvents(remoteStream);
+ }
+ const streamId = remoteStream.getId()?.toString() as string;
+ //重复设置 streamId 对应的stream
+ this.remoteStreamMap.set(streamId, remoteStream);
+ //仅仅订阅第一次收到的remoteStream
+ this.subscribeStream(this.remoteStream);
+ });
+
+ this.client?.on("stream-subscribed", (evt) => {
+ console.warn("[NERTC通知]: 远端流订阅成功: ", evt);
+ //订阅成功后,播放逻辑放到rtc外面去做
+ this.emit("stream-need-play", {
+ uid: evt.stream.getId()?.toString(),
+ mediaType: evt.mediaType,
+ });
+ });
+
+ this.client?.on("stream-removed", (evt) => {
+ const remoteStream = evt.stream;
+ console.warn(
+ "[NERTC通知]: 收到别人停止发布的消息: ",
+ remoteStream.getId(),
+ "mediaType: ",
+ evt.mediaType,
+ );
+ remoteStream.stop(evt.mediaType);
+ this.emit("stream-removed", {
+ uid: remoteStream.getId()?.toString(),
+ mediaType: evt.mediaType,
+ });
+ });
+
+ this.client?.on("network-quality", async (evt: NetStatusItem[]) => {
+ // console.log(
+ // "[NERTC通知]: 网络质量network-quality: " + JSON.stringify(evt),
+ // );
+ const networkInfo = { local: 0, remote: 0 };
+ evt.forEach((item) => {
+ if (item.uid.toString() === this.uid.toString()) {
+ networkInfo.local = item.uplinkNetworkQuality;
+ }
+ if (item.uid.toString() === this.remoteStream?.getId()?.toString()) {
+ networkInfo.remote = item.uplinkNetworkQuality;
+ }
+ });
+ this.emit("network-quality", networkInfo);
+ });
+ }
+
+ //初始化stream事件
+ initSreamEvents(stream: Stream) {
+ stream.on("notAllowedError", (evt) => {
+ //@ts-ignore
+ const errorCode = evt.getCode();
+ console.warn("收到音频自动播放受限的通知: ", stream.getId(), evt);
+ if (errorCode === 41030) {
+ console.warn("自动播放策略阻止:" + stream.getId());
+ }
+ });
+ }
+
+ initMediaDeviceEvents() {
+ this.client?.on("accessDenied", (type) => {
+ console.warn(`${type} 设备权限被禁止了`);
+ this.emit("DeviceError", {
+ mediaType: type,
+ reason: "accessDenied",
+ message: `${type} 设备权限被禁止了,请检查系统设置或者浏览器设置`,
+ });
+ });
+ this.client?.on("notFound", (type) => {
+ console.warn(`${type} 设备没有找到`);
+ this.emit("DeviceError", {
+ mediaType: type,
+ reason: "notFound",
+ message: `${type} 设备没有找到`,
+ });
+ });
+ this.client?.on("beOccupied", (type) => {
+ console.warn(`${type} 设备不可用, 系统或者设备驱动异常引起`);
+ this.emit("DeviceError", {
+ mediaType: type,
+ reason: "beOccupied",
+ message: `${type} 设备不可用, 设备被占用或者系统驱动异常引起,请重启应用或者系统`,
+ });
+ });
+ this.client?.on("deviceError", (type) => {
+ this.emit("DeviceError", {
+ mediaType: type,
+ reason: "deviceError",
+ message: `${type} 设备不支持设置的profile参数, 请换一个设备`,
+ });
+ });
+ this.localStream?.on("device-error", (data) => {
+ console.warn("设备异常:", data);
+ });
+ }
+
+ destroy() {
+ console.log("destroy()");
+ this.leaveChannel();
+ NERTC.destroy(this.client as Client);
+ }
+}
+
+export default RTCManager;
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/features/rtcPreview.tsx b/One-to-One-Video/NERtcSample-1to1-Web-React/src/features/rtcPreview.tsx
new file mode 100644
index 0000000..ab6ecad
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/features/rtcPreview.tsx
@@ -0,0 +1,261 @@
+import * as NERTC from "nertc-web-sdk";
+import type { Client } from "nertc-web-sdk/types/client";
+import type { Stream } from "nertc-web-sdk/types/stream";
+import { EventEmitter } from "eventemitter3";
+import { getRandomInt, getAppToken } from "@/utils";
+
+interface RTCPreviewOptions {
+ appkey: string;
+ secret: string;
+}
+class RTCPreview extends EventEmitter {
+ public client: Client | null = null;
+ public localStream: Stream | null = null;
+ public appkey: string = "";
+ public secret: string = "";
+ public networkInfo = {
+ uplinkNetworkQuality: 0,
+ downlinkNetworkQuality: 0,
+ };
+
+ constructor(options: RTCPreviewOptions) {
+ super();
+ console.log("RTCPreview constructor", options);
+ this.appkey = options.appkey;
+ this.secret = options.secret;
+ this.client = NERTC.createClient({
+ appkey: this.appkey,
+ debug: true,
+ });
+ this.localStream = NERTC.createStream({
+ audio: true,
+ video: true,
+ client: this.client,
+ }) as Stream;
+ }
+
+ //暴露localStream,方便直接调用sdk的接口API
+ get stream() {
+ return this.localStream;
+ }
+
+ async init() {
+ try {
+ this.initEvents();
+ // 检查麦克风、摄像头权限
+ await this.localStream?.init();
+ //获取麦克风设备列表
+ const microphoneDevices = await NERTC.getMicrophones();
+ let options = microphoneDevices.map((item) => ({
+ label: item.label, // 显示文本
+ value: item.deviceId, // 实际值
+ }));
+ this.emit("microphoneDevices", options);
+ //获取摄像头设备列表
+ const cameras = await NERTC.getCameras();
+ options = cameras.map((item) => ({
+ label: item.label, // 显示文本
+ value: item.deviceId, // 实际值
+ }));
+ this.emit("cameraDevices", options);
+ } catch (error: unknown) {
+ console.error("Error checking environment:", error);
+ this.emit("error", {
+ reason: "initError",
+ message: (error as Error).message,
+ });
+ }
+ }
+
+ initEvents() {
+ //@ts-ignore
+ //this.client?.removeAllListeners();
+ this.client?.on("accessDenied", (type) => {
+ console.warn(`${type} 设备权限被禁止了`);
+ this.emit("DeviceError", {
+ mediaType: type,
+ reason: "accessDenied",
+ message: `${type} 设备权限被禁止了,请检查系统设置或者浏览器设置`,
+ });
+ });
+ this.client?.on("notFound", (type) => {
+ console.warn(`${type} 设备没有找到`);
+ this.emit("DeviceError", {
+ mediaType: type,
+ reason: "notFound",
+ message: `${type} 设备没有找到`,
+ });
+ });
+ this.client?.on("beOccupied", (type) => {
+ console.warn(`${type} 设备不可用, 系统或者设备驱动异常引起`);
+ this.emit("DeviceError", {
+ mediaType: type,
+ reason: "beOccupied",
+ message: `${type} 设备被占用或者系统驱动异常引起,请重启应用或者系统`,
+ });
+ });
+ this.client?.on("deviceError", (type) => {
+ this.emit("DeviceError", {
+ mediaType: type,
+ reason: "deviceError",
+ message: `${type} 设备不支持设置的profile参数, 请换一个设备`,
+ });
+ });
+ //@ts-ignore
+ this.localStream?.removeAllListeners();
+ this.localStream?.on("device-error", (data) => {
+ console.warn("设备异常:", data);
+ });
+ }
+
+ async supportRTC() {
+ // 检查是否支持NERTC
+ const checkResult = await NERTC.checkBrowserCompatibility();
+ let supportRTC = false;
+ if (
+ checkResult &&
+ checkResult.isPushStreamSupport &&
+ checkResult.isPushStreamSupport
+ ) {
+ supportRTC = true;
+ }
+ return supportRTC;
+ }
+
+ async getMicrophones() {
+ const microphoneDevices = await NERTC.getMicrophones();
+ let options = microphoneDevices.map((item) => ({
+ label: item.label, // 显示文本
+ value: item.deviceId, // 实际值
+ }));
+ return options;
+ }
+
+ async getCameras() {
+ const cameras = await NERTC.getCameras();
+ const options = cameras.map((item) => ({
+ label: item.label, // 显示文本
+ value: item.deviceId, // 实际值
+ }));
+ return options;
+ }
+
+ async checkNetwork() {
+ //实现逻辑为,使用client作为推流端,然后使用downClient作为拉流端
+ return new Promise(async (resolve, reject) => {
+ try {
+ console.warn("开始检测网络质量");
+ const upUid = getRandomInt(100, 10000); //随机用户Id
+ const downUid = getRandomInt(100, 10000); //随机用户Id
+ const channelName = "TEST_" + Date.now(); //RTC房间名,加上时间戳防止干扰
+ const upClient = this.client as Client;
+ const downClient = NERTC.createClient({
+ appkey: this.appkey,
+ debug: true,
+ });
+ //监听上行推流用户实际网络质量
+ upClient.on("network-quality", (evt) => {
+ console.log("upClient network-quality: " + JSON.stringify(evt)); //检测用户上行的网络质量
+ });
+ //监听下行拉流用户实际网络质量
+ downClient.on("network-quality", async (evt) => {
+ console.log("downClient network-quality: " + JSON.stringify(evt)); //检测用户下行的网络质量
+ let result = evt.find((item) => item.uid === upUid);
+ this.networkInfo.uplinkNetworkQuality =
+ result?.uplinkNetworkQuality as number;
+ result = evt.find((item) => item.uid === downUid);
+ this.networkInfo.downlinkNetworkQuality =
+ result?.downlinkNetworkQuality as number;
+ });
+ await this.upClientPub(upUid, channelName, upClient);
+ await this.downClientSub(downUid, channelName, downClient);
+ //服务器检查网络质量需要时间,这里限制5秒
+ setTimeout(async () => {
+ console.warn(
+ "检测网络质量结束: ",
+ JSON.stringify(this.networkInfo, null, " "),
+ );
+ resolve(this.networkInfo);
+ //清除资源
+ await downClient.leave();
+ downClient.destroy();
+ NERTC.destroy(downClient);
+ upClient.leave();
+ }, 5 * 1000);
+ } catch (error) {
+ console.error("checkNetwork error: ", (error as Error).message);
+ reject({
+ reason: "initError",
+ message: (error as Error).message,
+ });
+ }
+ });
+ }
+
+ async upClientPub(uid: number, channelName: string, upClient: Client) {
+ try {
+ console.log("upClientPub... " + uid + " channelName: " + channelName);
+ const token = await getAppToken({
+ appkey: this.appkey,
+ secret: this.secret,
+ uid: uid.toString(),
+ channelName,
+ });
+ await upClient.join({
+ channelName,
+ uid,
+ token,
+ });
+ console.log("upClient加入房间成功..." + uid);
+ if (!this.localStream) {
+ return;
+ }
+ await upClient.publish(this.localStream);
+ console.log("本地 publish 成功");
+ } catch (error) {
+ console.error("upClientPub error: ", (error as Error).message);
+ }
+ }
+
+ async downClientSub(uid: number, channelName: string, downClient: Client) {
+ try {
+ //回调事件-远端用户已发流
+ downClient.on("stream-added", (evt) => {
+ console.log("远端有流来: " + evt.mediaType);
+ const remoteStream = evt.stream;
+ remoteStream.setSubscribeConfig({
+ audio: true,
+ video: true,
+ });
+ downClient.subscribe(remoteStream);
+ });
+ //回调事件-远程音视频流已订阅
+ downClient.on("stream-subscribed", (evt) => {
+ console.log("订阅远端流成功: ", evt);
+ //此处仅仅是坚持网络连接,不需要渲染订视频流
+ });
+ const token = await getAppToken({
+ appkey: this.appkey,
+ secret: this.secret,
+ uid: uid.toString(),
+ channelName,
+ });
+ await downClient.join({
+ channelName,
+ uid,
+ token,
+ });
+ console.log("接收方加入房间成功...");
+ } catch (error) {
+ console.error("downClientSub error: ", (error as Error).message);
+ }
+ }
+
+ destroy() {
+ console.log("RTCPreview destroy()");
+ this.localStream?.destroy();
+ this.client?.destroy();
+ NERTC.destroy(this.client as Client);
+ }
+}
+export default RTCPreview;
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/global.d.ts b/One-to-One-Video/NERtcSample-1to1-Web-React/src/global.d.ts
new file mode 100644
index 0000000..f71325d
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/global.d.ts
@@ -0,0 +1,22 @@
+declare module "*.css";
+declare module "*.png" {
+ const src: string;
+ export default src;
+}
+declare module "*.jpg" {
+ const src: string;
+ export default src;
+}
+declare module "*.jpeg" {
+ const src: string;
+ export default src;
+}
+declare module "*.gif" {
+ const src: string;
+ export default src;
+}
+declare module "*.svg" {
+ const src: string;
+ export default src;
+}
+declare module "nertc-web-sdk/NERTC_Web_SDK_AIAudioEffects.js";
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/hooks/useVisibility.ts b/One-to-One-Video/NERtcSample-1to1-Web-React/src/hooks/useVisibility.ts
new file mode 100644
index 0000000..52f898c
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/hooks/useVisibility.ts
@@ -0,0 +1,26 @@
+// useVisibility.ts
+import { useEffect, useState } from "react";
+import BrowserVisibilityMonitor from "@/features/browserVisibilityMonitor";
+import type { VisibilityState } from "@/features/browserVisibilityMonitor";
+
+const useVisibility = (): VisibilityState => {
+ const [visibility, setVisibility] = useState("visible");
+
+ useEffect(() => {
+ const monitor = new BrowserVisibilityMonitor();
+ setVisibility(monitor.getState());
+
+ const unsubscribe = monitor.on("change", (state: string) => {
+ setVisibility(state as VisibilityState);
+ });
+
+ return () => {
+ unsubscribe();
+ monitor.destroy();
+ };
+ }, []);
+
+ return visibility;
+};
+
+export default useVisibility;
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/index.css b/One-to-One-Video/NERtcSample-1to1-Web-React/src/index.css
new file mode 100644
index 0000000..d681a6e
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/index.css
@@ -0,0 +1,69 @@
+:root {
+ font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
+ line-height: 1.5;
+ font-weight: 400;
+
+ color-scheme: light dark;
+ color: rgba(255, 255, 255, 0.87);
+ background-color: #242424;
+
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+a {
+ font-weight: 500;
+ color: #646cff;
+ text-decoration: inherit;
+}
+a:hover {
+ color: #535bf2;
+}
+
+body {
+ margin: 0;
+ display: flex;
+ background-color: #f7f8fa;
+ /* place-items: center; */
+ min-width: 320px;
+ min-height: 100vh;
+}
+
+h1 {
+ font-size: 3.2em;
+ line-height: 1.1;
+}
+
+button {
+ border-radius: 8px;
+ border: 1px solid transparent;
+ padding: 0.6em 1.2em;
+ font-size: 1em;
+ font-weight: 500;
+ font-family: inherit;
+ background-color: #1a1a1a;
+ cursor: pointer;
+ transition: border-color 0.25s;
+}
+button:hover {
+ border-color: #646cff;
+}
+button:focus,
+button:focus-visible {
+ outline: 4px auto -webkit-focus-ring-color;
+}
+
+@media (prefers-color-scheme: light) {
+ :root {
+ color: #213547;
+ background-color: #ffffff;
+ }
+ a:hover {
+ color: #747bff;
+ }
+ button {
+ background-color: #f9f9f9;
+ }
+}
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/main.tsx b/One-to-One-Video/NERtcSample-1to1-Web-React/src/main.tsx
new file mode 100644
index 0000000..5cf93e9
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/main.tsx
@@ -0,0 +1,11 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { RouterProvider } from 'react-router-dom'
+import router from './routes/router'
+import './index.css'
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+)
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/pages/home/index.css b/One-to-One-Video/NERtcSample-1to1-Web-React/src/pages/home/index.css
new file mode 100644
index 0000000..27d5040
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/pages/home/index.css
@@ -0,0 +1,22 @@
+.home-body {
+ height: calc(100% - 46px);
+ background: #f7f8fa;
+ display: flex;
+ align-items: baseline;
+ justify-content: center;
+}
+
+.content {
+ width: 94vw;
+ height: 94vw;
+ padding-top: 10vw;
+ background: #fff;
+ box-shadow: 0 4px 10px 0 rgba(47, 56, 111, 0.1);
+ border-radius: 8px;
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ justify-content: start;
+ gap: 5vw;
+ margin-top: 30vw;
+}
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/pages/home/index.tsx b/One-to-One-Video/NERtcSample-1to1-Web-React/src/pages/home/index.tsx
new file mode 100644
index 0000000..6ff9706
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/pages/home/index.tsx
@@ -0,0 +1,120 @@
+import { useAppContext } from "@/store/index";
+import { useEffect, useState } from "react";
+import { Image, Input, Button, Toast, WaterMark } from "antd-mobile";
+import { useNavigate } from "react-router-dom";
+import imgSrc from "@/assets/yunxinLogo1.png";
+import { getSystemInfo } from "@/utils";
+import WechatQrCode from "@/components/wechatQrCode";
+
+import "./index.css";
+const Home: React.FC = () => {
+ const { meetingInfo, setMeetingInfo } = useAppContext();
+ //全局变量保存房间信息
+ const [nickName, setNickName] = useState("");
+ const [channelName, setChannelName] = useState("");
+ const [useWxChat, setUseWxChat] = useState(false);
+ const [disabled, setDisabled] = useState(false);
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ const { os, osVersion, isMobile, isWeChat } = getSystemInfo();
+ console.log("getSystemInfo() :", os, osVersion, isMobile, isWeChat);
+ if (!isMobile) {
+ console.warn("非移动端");
+ setDisabled(true);
+ Toast.show({
+ content: "请使用移动端浏览器打开",
+ icon: "fail",
+ duration: 5000,
+ });
+ return;
+ }
+ // 安卓、鸿蒙系统推荐使用微信浏览器打开
+ // ios系统推荐使用safari浏览器打开
+ // ps: 使用URL Scheme或者App Links唤起微信都有各自的缺陷,所以这里推荐使用引导的方式
+ if (!isWeChat && (os === "Android" || os === "Harmony")) {
+ console.log("安卓系统推荐使用微信浏览器打开");
+ setUseWxChat(true);
+ }
+ if (os === "iOS" && parseInt(osVersion as string) < 13) {
+ setDisabled(true);
+ Toast.show({
+ content: "IOS推荐系统版本13+",
+ icon: "fail",
+ duration: 5000,
+ });
+ }
+ }, []);
+ useEffect(() => {
+ console.log("meetingInfo:", meetingInfo);
+ if (meetingInfo.channelName) {
+ console.log("进入preview页面");
+ navigate("/preview");
+ }
+ }, [meetingInfo]);
+
+ const joinChannel = () => {
+ console.log("joinChannel()");
+ setMeetingInfo({ nickName, channelName });
+ };
+
+ return (
+
+
+ {useWxChat ? (
+ //引导微信浏览器打开当前页面
+
+ ) : (
+
+
+ {
+ setChannelName(val);
+ }}
+ style={{
+ "--color": "#0a43e5",
+ "--placeholder-color": "#000000ad",
+ "--font-size": "18px",
+ height: 44,
+ width: 300,
+ borderBottom: "1px solid #dcdfe5",
+ marginTop: 20,
+ fontSize: 18,
+ }}
+ />
+ {
+ setNickName(val);
+ }}
+ style={{
+ "--color": "#0a43e5",
+ "--placeholder-color": "#000000ad",
+ "--font-size": "18px",
+ height: 44,
+ width: 300,
+ borderBottom: "1px solid #dcdfe5",
+ fontSize: 18,
+ }}
+ />
+
+
+ )}
+
+ );
+};
+
+export default Home;
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/pages/preview/index.css b/One-to-One-Video/NERtcSample-1to1-Web-React/src/pages/preview/index.css
new file mode 100644
index 0000000..e753953
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/pages/preview/index.css
@@ -0,0 +1,198 @@
+.preview-body {
+ height: calc(100% - 46px);
+ background: #f7f8fa;
+ display: flex;
+ align-items: center;
+ justify-content: start;
+ flex-direction: column;
+ position: relative;
+}
+.preview-body-header {
+ position: absolute;
+ top: 10px;
+ right: 1px;
+ background-color: #07c160;
+ /* border: 1px solid #008000cf; */
+ color: #fff;
+ line-height: 1;
+ font-size: 1.07692308em;
+ padding: 0.61538462em 1.23076923em;
+ z-index: 10000;
+ border-radius: 0.30769231em;
+ box-shadow: 0 0 0.61538462em rgba(0, 0, 0, 0.4);
+}
+
+.checked-body {
+ position: relative;
+ width: 94vw;
+ height: 94vw;
+ background: #fff;
+ box-shadow: 0 4px 10px 0 rgba(47, 56, 111, 0.1);
+ border-radius: 8px;
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ justify-content: center;
+ margin-top: 15vw;
+ gap: 20px;
+}
+
+.checked-body-title {
+ font-size: 24px;
+}
+
+.checked-body-text {
+ font-size: 14px;
+ color: #0000009e;
+}
+
+.checked-body-steps {
+ width: 340px;
+ width: 340px;
+ position: absolute;
+ top: 10px;
+}
+.adm-steps .adm-step .adm-step-icon {
+ margin-bottom: 22px; /* 增加或减少这个值 */
+}
+
+.checked-body-steps-item {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ justify-content: center;
+ margin-top: 20px;
+ gap: 10px;
+}
+
+.checked-body-steps-microphone-devices {
+ display: flex;
+ align-items: center;
+ flex-direction: row;
+ justify-content: center;
+ gap: 15px;
+}
+
+.checked-body-steps-microphone-devices-title {
+ font-size: 14px;
+}
+
+.checked-body-steps-microphone-volume {
+ display: flex;
+ align-items: baseline;
+ flex-direction: column;
+ justify-content: center;
+ gap: 5px;
+ margin-top: 10px;
+ width: 286;
+}
+
+.checked-body-steps-microphone-volume-title {
+ font-size: 14px;
+}
+
+.checked-body-steps-microphone-callback {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ justify-content: center;
+ gap: 10px;
+ margin-top: 30px;
+}
+
+.checked-body-steps-microphone-callback-title {
+ font-size: 13px;
+}
+
+.checked-body-steps-microphone-callback-btn {
+ display: flex;
+ align-items: center;
+ flex-direction: row;
+ justify-content: center;
+ gap: 50px;
+}
+
+.checked-body-steps-speaker {
+ display: flex;
+ align-items: baseline;
+ flex-direction: column;
+ justify-content: center;
+ gap: 10px;
+}
+
+.checked-body-steps-camera-callback-btn {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ justify-content: center;
+ gap: 10px;
+}
+
+.checked-body-steps-item-network {
+ display: flex;
+ align-items: baseline;
+ flex-direction: column;
+ justify-content: center;
+ width: 280px;
+ margin-top: 10px;
+}
+
+.checked-body-steps-network {
+ display: flex;
+ align-items: center;
+ flex-direction: row;
+ justify-content: space-between;
+ margin-top: 20px;
+ width: 280px;
+}
+
+.checked-body-steps-item-report {
+ display: flex;
+ align-items: center;
+ flex-direction: row;
+ justify-content: space-between;
+}
+
+.checked-body-steps-report {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ justify-content: space-between;
+}
+
+.checked-body-steps-network-title {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.checked-body-steps-network-description {
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+
+.checked-body-steps-network-title-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-direction: row;
+ width: 220px;
+ gap: 10px;
+}
+
+.checked-body-steps-item-report {
+ display: flex;
+ align-items: baseline;
+ flex-direction: column;
+ justify-content: center;
+ width: 280px;
+}
+
+.checked-body-steps-report-btn {
+ display: flex;
+ align-items: center;
+ flex-direction: row;
+ justify-content: center;
+ gap: 50px;
+ margin-top: 10px;
+}
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/pages/preview/index.tsx b/One-to-One-Video/NERtcSample-1to1-Web-React/src/pages/preview/index.tsx
new file mode 100644
index 0000000..cfa12c4
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/pages/preview/index.tsx
@@ -0,0 +1,758 @@
+import { useEffect, useState, useRef } from "react";
+import {
+ AudioOutline,
+ VideoOutline,
+ SoundOutline,
+ GlobalOutline,
+} from "antd-mobile-icons";
+import { Button, Space, Steps, Toast } from "antd-mobile";
+import { Select } from "antd";
+import { useNavigate } from "react-router-dom";
+import "./index.css";
+import RTCPreview from "@/features/rtcPreview";
+import { appkey, secret } from "@/config";
+import { useAppContext } from "@/store/index";
+import type {
+ NetworkInfo,
+ DeviceCheckResult,
+ NetworkStatus,
+ NetworkStatusText,
+} from "../../types/index";
+import { getSystemInfo } from "@/utils";
+
+const systemInfo = getSystemInfo();
+
+const Preview: React.FC = () => {
+ const navigate = useNavigate();
+ const { meetingInfo, setMeetingInfo } = useAppContext();
+ const { Step } = Steps;
+ const [checking, setChecking] = useState(false);
+ const [stepIndex, setStedIndex] = useState(-1);
+ const [volume, setVolume] = useState(0);
+ const [networkInfo, setNetworkInfo] = useState({
+ osType: systemInfo.os as string,
+ userAgent: `${systemInfo.browser} ${systemInfo.browserVersion}`,
+ supportRTC: false,
+ uplinkNetworkQuality: "UNKNOWN",
+ downlinkNetworkQuality: "UNKNOWN",
+ });
+ const [showReport, setShowReport] = useState(false);
+ const [microphoneDeviceList, setMicrophoneDeviceList] = useState<
+ { value: string; label: string }[]
+ >([]);
+ const [microphoneDefaultValue, setMicrophoneDefaultValue] = useState({
+ label: "",
+ deviceId: "",
+ });
+ const [cameraDeviceList, setCameraDeviceList] = useState<
+ { value: string; label: string }[]
+ >([]);
+ const [cameraDefaultValue, setCameraDefaultValue] = useState({
+ label: "",
+ deviceId: "",
+ });
+ const [loading, setLoading] = useState(true);
+ const videoRef = useRef(null);
+ const rtcPreviewRef = useRef(null);
+ const rtcPreviewResultRef = useRef({
+ microphone: {
+ isOK: false,
+ },
+ speaker: {
+ isOK: false,
+ },
+ camera: {
+ isOK: false,
+ },
+ network: networkInfo,
+ });
+
+ const NETWORK_STATUS_MAP: Record = {
+ 0: "UNKNOWN",
+ 1: "EXCELLENT",
+ 2: "GOOD",
+ 3: "POOR",
+ 4: "BAD",
+ 5: "VERYBAD",
+ 6: "DOWN",
+ };
+
+ useEffect(() => {
+ console.log("meetingInfo:", meetingInfo);
+ if (!meetingInfo.channelName) {
+ navigate("/");
+ }
+ }, []);
+ useEffect(() => {
+ if (!rtcPreviewRef.current || !rtcPreviewRef.current.stream) {
+ return;
+ }
+ if (stepIndex === 0) {
+ const timer = setInterval(() => {
+ setVolume(rtcPreviewRef.current?.stream?.getAudioLevel() as number);
+ }, 500);
+ return () => clearInterval(timer);
+ } else if (stepIndex === 2) {
+ rtcPreviewRef.current.stream.play(videoRef.current as HTMLDivElement);
+ rtcPreviewRef.current.stream.setLocalRenderMode({
+ width: videoRef.current?.clientWidth as number,
+ height: videoRef.current?.clientHeight as number,
+ cut: false,
+ });
+ }
+ }, [stepIndex, rtcPreviewRef.current]);
+
+ const convertQuality = (quality: NetworkStatus): NetworkStatusText => {
+ return NETWORK_STATUS_MAP[quality];
+ };
+
+ const repeat = () => {
+ console.log("重新检测");
+ setShowReport(false);
+ startPreview();
+ };
+
+ const startPreview = () => {
+ console.log("开始检测");
+ setChecking(true);
+ setStedIndex(0);
+ rtcPreviewRef.current = new RTCPreview({
+ appkey,
+ secret: secret,
+ });
+ checkEnvironment();
+ };
+
+ const handleMicrophoneDeviceChange = (value: string, options: any) => {
+ console.log(`麦克风选择selected ${value}`);
+ console.log("options", options);
+ setMicrophoneDefaultValue({
+ label: options.label,
+ deviceId: options.value,
+ });
+ setMeetingInfo({ ...meetingInfo, microphoneId: options.value });
+ // 记录选择的麦克风设备,后续加入房间时使用
+ rtcPreviewRef.current?.stream?.switchDevice("audio", value);
+ };
+
+ const handleCameraDeviceChange = (value: string, options: any) => {
+ console.log(`摄像头选择selected ${value}`);
+ setCameraDefaultValue({
+ label: options.label,
+ deviceId: options.value,
+ });
+ rtcPreviewRef.current?.stream?.switchDevice("video", value);
+ // 记录选择的摄像头设备,后续加入房间时使用
+ setMeetingInfo({ ...meetingInfo, cameraId: options.value });
+ };
+
+ const microphoneCallback = (isOK: boolean) => {
+ console.log("反馈麦克风采集的音量 isOK:", isOK);
+ console.log("反馈麦克风采集的音量 volume:", volume);
+ // if (!volume) {
+ // Toast.show({
+ // content: '请检查选择的麦克风设备是否正常',
+ // icon: 'success'
+ // })
+ // return
+ // }
+ rtcPreviewResultRef.current.microphone.isOK = isOK;
+ setStedIndex(1);
+ };
+
+ const speakerCallback = (isOK: boolean) => {
+ console.log("反馈扬声器 isOK:", isOK);
+ rtcPreviewResultRef.current.speaker.isOK = isOK;
+ setStedIndex(2);
+ };
+
+ const cameraCallback = (isOK: boolean) => {
+ console.log("反馈摄像头 isOK:", isOK);
+ rtcPreviewResultRef.current.camera.isOK = isOK;
+ setStedIndex(3);
+ checkNetwork();
+ };
+
+ const checkNetwork = async () => {
+ try {
+ setLoading(true);
+ const result = (await rtcPreviewRef.current?.checkNetwork()) as {
+ uplinkNetworkQuality: NetworkStatus;
+ downlinkNetworkQuality: NetworkStatus;
+ };
+ setLoading(false);
+ console.log("检测网络结果: ", result);
+ const transformedResult = {
+ uplinkNetworkQuality: convertQuality(result.uplinkNetworkQuality),
+ downlinkNetworkQuality: convertQuality(result.downlinkNetworkQuality),
+ };
+ setNetworkInfo((prevState) => ({ ...prevState, ...transformedResult }));
+ //rtcPreviewResultRef.current.network = {...networkInfo, ...transformedResult}
+ rtcPreviewRef.current?.destroy();
+ rtcPreviewRef.current = null;
+ } catch (error) {
+ setLoading(false);
+ const message = "检测网络错误: " + (error as Error).message;
+ console.error(message);
+ Toast.show({
+ content: message,
+ icon: "fail",
+ });
+ }
+ };
+
+ const enterMeetingRoom = () => {
+ console.log("进入会议");
+ navigate("/rtc");
+ };
+
+ const checkEnvironment = async () => {
+ try {
+ if (!rtcPreviewRef.current) {
+ return;
+ }
+ rtcPreviewRef.current.on(
+ "DeviceError",
+ (result: {
+ mediaType: "audio" | "video";
+ reason: string;
+ message: string;
+ }) => {
+ console.error("检测到设备异常: ", JSON.stringify(result, null, " "));
+ Toast.show({
+ content: result.message,
+ icon: "fail",
+ });
+ },
+ );
+ rtcPreviewRef.current.on(
+ "error",
+ (result: { reason: string; message: string }) => {
+ console.error(
+ "检测流程内部异常: ",
+ JSON.stringify(result, null, " "),
+ );
+ Toast.show({
+ content: result.reason + result.message,
+ icon: "fail",
+ });
+ },
+ );
+ //初始化
+ await rtcPreviewRef.current.init();
+ console.log("初始化完成");
+ setLoading(false);
+ // 检查是否支持NERTC
+ const supportRTC = await rtcPreviewRef.current.supportRTC();
+ setNetworkInfo((prevState) => ({ ...prevState, supportRTC }));
+ //获取麦克风设备列表
+ const microphones = await rtcPreviewRef.current.getMicrophones();
+ setMicrophoneDeviceList(microphones);
+ setMicrophoneDefaultValue({
+ label: microphones[0].label,
+ deviceId: microphones[0].value,
+ });
+ //获取摄像头设备列表
+ const cameras = await rtcPreviewRef.current.getCameras();
+ setCameraDeviceList(cameras);
+ setCameraDefaultValue({
+ label: microphones[0].label,
+ deviceId: microphones[0].value,
+ });
+ } catch (error: unknown) {
+ console.error("Error checking environment:", error);
+ Toast.show({
+ content: "设备检测错误: " + (error as Error).message,
+ icon: "fail",
+ afterClose: () => {
+ console.log("after");
+ },
+ });
+ setLoading(false);
+ }
+ };
+
+ const skip = () => {
+ if (checking) {
+ rtcPreviewRef.current?.destroy();
+ rtcPreviewRef.current = null;
+ setChecking(false);
+ }
+ enterMeetingRoom();
+ };
+
+ // 渲染30个柱状图
+ const renderVolumeBars = () => {
+ const totalBars = 30;
+ // 计算需要显示为绿色的柱子数量
+ const activeBars = Math.round((volume / 100) * totalBars);
+ return (
+
+ {Array.from({ length: totalBars }).map((_, index) => {
+ const isActive = index < activeBars;
+ return (
+
+ );
+ })}
+
+ );
+ };
+
+ return (
+
+
+ 跳过检测
+
+ {checking ? (
+
+ {!showReport ? (
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+ ) : null}
+
+ {/* 麦克风检测UI */}
+ {stepIndex === 0 && !showReport ? (
+
+
+
+
+
+ 对着麦克风说话
+
+ {/*
*/}
+ {renderVolumeBars()}
+
+
+
+
+ 是否可以看到音量图标跳动?
+
+
+
+
+
+
+
+ ) : null}
+
+ {/* 扬声器检测UI */}
+ {stepIndex === 1 ? (
+
+
+
+
+
+ 是否可以听到声音?
+
+
+
+
+
+
+
+ ) : null}
+
+ {/* 摄像头检测UI */}
+ {stepIndex === 2 ? (
+
+
+
+
+
+ 是否可以看到自己?
+
+
+
+
+
+
+
+ ) : null}
+
+ {/* 网络检测UI */}
+ {stepIndex === 3 && !showReport ? (
+
+
+
+
+ 操作系统
+
+
+ {networkInfo.osType}
+
+
+
+
浏览器
+
+ {networkInfo.userAgent}
+
+
+
+
+ 支持NERTC
+
+
+ {networkInfo.supportRTC ? "支持" : "不支持"}
+
+
+
+
+ 上行网络质量
+
+
+ {networkInfo.uplinkNetworkQuality}
+
+
+
+
+ 下行网络质量
+
+
+ {networkInfo.downlinkNetworkQuality}
+
+
+
+
+
+
+
+
+
+
+
+ ) : null}
+
+ {/* 检查报告UI */}
+ {showReport ? (
+
+
检查报告
+
+
+
+
+
+
+ {microphoneDefaultValue.label}
+
+
+ {rtcPreviewResultRef.current.microphone.isOK
+ ? "正常"
+ : "异常"}
+
+
+
+
+
+
+
+ 系统扬声器
+
+
+ {rtcPreviewResultRef.current.speaker.isOK
+ ? "正常"
+ : "异常"}
+
+
+
+
+
+
+
+ {cameraDefaultValue.label}
+
+
+ {rtcPreviewResultRef.current.camera.isOK
+ ? "正常"
+ : "异常"}
+
+
+
+
+
+
+
+ 上行网络质量
+
+
+ {networkInfo.uplinkNetworkQuality}
+
+
+
+
+
+
+
+ 下行网络质量
+
+
+ {networkInfo.downlinkNetworkQuality}
+
+
+
+
+
+
+
+
+
+ ) : null}
+
+ ) : (
+
+
环境监测
+
+ 检查前请确保网络通畅,摄像头和麦克风正常工作
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default Preview;
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/pages/rtc/index.css b/One-to-One-Video/NERtcSample-1to1-Web-React/src/pages/rtc/index.css
new file mode 100644
index 0000000..3e7bda6
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/pages/rtc/index.css
@@ -0,0 +1,64 @@
+.rtc-body {
+ height: calc(100% - 46px);
+ width: 100vw;
+ background-image: linear-gradient(179deg, #141417, #181824);
+ display: flex;
+ flex-direction: column;
+ position: relative;
+}
+
+.rtc-main-window {
+ height: 100%;
+ width: 100%;
+ margin: 0 auto;
+ background: #25252d;
+ position: relative;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 22px;
+ color: #fff;
+}
+
+.rtc-main-window-statistics-box {
+ position: absolute;
+ top: 10px;
+ left: 10px;
+ background-color: rgba(30, 30, 30, 0.6);
+ height: auto;
+ color: green;
+ font-size: 12px;
+ padding: 5px;
+ z-index: 9;
+ display: flex;
+ align-items: baseline;
+ flex-direction: column;
+}
+
+.rtc-main-window-small-video-wrapper {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ z-index: 9;
+}
+
+.rtc-main-window-small-video {
+ background: #25252d;
+ border: 1px solid #fff;
+ width: 25vw; /* 宽度为视口宽度的25%(响应式) */
+ aspect-ratio: 3/4; /* 固定宽高比(竖屏9:16) */
+ border-radius: 8px; /* 圆角 */
+ text-align: center;
+}
+
+.rtc-tab-bar {
+ height: 54px;
+ width: 100%;
+ background-image: linear-gradient(180deg, #292933 7%, #212129);
+ box-shadow: 0 0 0 0 hsla(0, 0%, 100%, 0.3);
+ list-style: none;
+ display: flex;
+ justify-content: space-evenly;
+ align-items: center;
+ color: #fff;
+}
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/pages/rtc/index.tsx b/One-to-One-Video/NERtcSample-1to1-Web-React/src/pages/rtc/index.tsx
new file mode 100644
index 0000000..93c2443
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/pages/rtc/index.tsx
@@ -0,0 +1,767 @@
+import React, { useState, useEffect, useRef, useCallback } from "react";
+import "./index.css";
+import type { MediaInfo } from "@/types";
+import { Toast } from "antd-mobile";
+import { useNavigate } from "react-router-dom";
+import { SoundMuteOutline } from "antd-mobile-icons";
+import muteIcon from "@/assets/mute.png";
+import unmuteIcon from "@/assets/unmute.png";
+import closeCameraIcon from "@/assets/camera-opned.png";
+import openCameraIcon from "@/assets/camera-closed.png";
+import flipCameraIcon from "@/assets/camera-flip.png";
+import overIcon from "@/assets/over.png";
+import { appkey, secret } from "@/config";
+import Pip from "@/components/Pip";
+import type { PipHandle } from "@/components/Pip";
+import NetworkSignal from "@/components/networkSignal";
+import type { SignalLevel } from "@/components/networkSignal";
+import RTCManager from "@/features/rtcManager";
+import { useAppContext } from "@/store/index";
+import useVisibility from "@/hooks/useVisibility";
+import { throttle } from "lodash-es";
+import type { PEER_LEAVE_REASON_CODE } from "nertc-web-sdk/types/types";
+
+const RTC: React.FC = () => {
+ const navigate = useNavigate();
+ const visibility = useVisibility();
+ const { meetingInfo, setMeetingInfo } = useAppContext();
+ const [isMuted, setIsMuted] = useState(false);
+ const remoteUid = useRef("");
+ const rtcManagerRef = useRef(null);
+ const largeVideoRef = useRef(null);
+ const smallVideoRef = useRef(null);
+ const [meidaInfo, setMediaInfo] = useState({
+ audioLevel: 0,
+ audioBitrate: 0,
+ videoBitrate: 0,
+ videoFrameRate: 0,
+ videoFrameWidth: 0,
+ videoFrameHeight: 0,
+ uplinkNetworkQuality: 0,
+ downlinkNetworkQuality: 0,
+ });
+
+ const [localSignalLevel, setLocalSignalLevel] = useState(3); // 初始强信号
+ const [remoteSignalLevel, setRemoteSignalLevel] = useState(3); // 初始强信号
+ const [muted, setMuted] = useState(false);
+ const [cameraOpened, setCameraOpened] = useState(true);
+ const [facingMode, setFacingMode] = useState<"user" | "environment">("user"); // 默认前置摄像头
+ const [isLocalVideoLarge, setIsLocalVideoLarge] = useState(false);
+
+ useEffect(() => {
+ //这一段是示例代码,业务方需要自己实现
+ const sendFinalRequest = () => {
+ console.log("页面离开主动通知服务器,广播给房间中的其他人");
+ return;
+ //下面是示例代码,本Demo没有业务服务器所以没有实现这部分逻辑
+ // 客户自己可以实现自己的业务服务器,然后调用下面的接口
+ const analyticsData = {
+ page: window.location.pathname,
+ content: "本人主动离开了页面,需要服务器感知",
+ // 其他需要上报的数据
+ };
+
+ // 使用Blob可以发送更复杂的数据结构
+ const blob = new Blob([JSON.stringify(analyticsData)], {
+ type: "application/json; charset=UTF-8",
+ });
+
+ /**
+ * navigator.sendBeacon() 是一个专门为在页面卸载时可靠地发送数据到服务器而设计的API。
+ * - 页面卸载时仍能可靠发送
+ * - 异步不阻塞页面卸载
+ * - 自动处理网络问题
+ */
+ //发送给你的业务服务器
+ navigator.sendBeacon("your-api-endpoint", blob);
+ };
+
+ // 同时监听多个事件确保覆盖各种情况
+ const events = ["pagehide", "beforeunload", "unload"];
+ events.forEach((event) => {
+ window.addEventListener(event, sendFinalRequest);
+ });
+
+ return () => {
+ events.forEach((event) => {
+ window.removeEventListener(event, sendFinalRequest);
+ });
+ };
+ }, []);
+
+ React.useEffect(() => {
+ //这里可以监听页面可见性变化,当页面不可见时mute视频
+ //PS: visibility目前判断的不够准确,先忽略
+ console.log(`当前可见性状态: ${visibility}`);
+ return;
+ if (visibility === "hidden") {
+ // 页面进入后台时的逻辑
+ console.log("页面进入后台,mute视频");
+ rtcManagerRef.current?.nertLocalStream?.muteVideo();
+ } else {
+ // 页面回到前台时的逻辑
+ console.log("页面回到前台,unmute视频");
+ rtcManagerRef.current?.nertLocalStream?.unmuteVideo();
+ }
+ }, [visibility]);
+
+ useEffect(() => {
+ console.log("meetingInfo:", meetingInfo);
+ const catchMeetingInfo = localStorage.getItem("meetingInfo");
+ console.log("catchMeetingInfo:", catchMeetingInfo);
+ let channelName = meetingInfo.channelName;
+ if (catchMeetingInfo && !channelName) {
+ channelName = JSON.parse(catchMeetingInfo).channelName;
+ setMeetingInfo(JSON.parse(catchMeetingInfo));
+ }
+ if (!channelName) {
+ navigate("/");
+ }
+ if (channelName && !rtcManagerRef.current) {
+ createNERTCEngine(channelName);
+ }
+ return () => {
+ if (rtcManagerRef.current) {
+ rtcManagerRef.current.destroy();
+ rtcManagerRef.current = null;
+ }
+ };
+ }, []);
+
+ useEffect(() => {
+ if (!rtcManagerRef.current?.localView) {
+ return;
+ }
+ if (
+ rtcManagerRef.current.localView === smallVideoRef.current?.getElement()
+ ) {
+ setIsLocalVideoLarge(false);
+ } else {
+ //此时进行了大小屏切换,右上角展示的是远端视频画面
+ setIsLocalVideoLarge(true);
+ }
+ }, [rtcManagerRef.current?.localView]);
+
+ const createNERTCEngine = async (channelName: string) => {
+ console.warn("开始创建NERTC引擎");
+ rtcManagerRef.current = new RTCManager({
+ appkey,
+ secret,
+ });
+ //@ts-ignore
+ window.rtc = rtcManagerRef.current;
+ initRTCManagerEvents();
+ initNERTCEvents();
+ await join(channelName);
+ };
+
+ const join = async (channelName: string) => {
+ try {
+ console.log("开始加入房间");
+ await rtcManagerRef.current?.joinChannel({
+ channelName,
+ });
+ console.log("加入房间成功, 开始初始化本地流");
+ //保存到本地,用于刷新当前页面进入房间的场景
+ localStorage.setItem("meetingInfo", JSON.stringify(meetingInfo));
+ await initLocalStream();
+ } catch (error) {
+ const message = "加入房间失败: " + (error as Error).message;
+ console.error(message);
+ Toast.show({
+ content: message,
+ icon: "fail",
+ duration: 5000,
+ });
+ }
+ };
+
+ const initLocalStream = async () => {
+ console.log("开始初始化本地流");
+ //使用检测页面选择的摄像头
+ await rtcManagerRef.current?.initLocalStream({
+ audio: true,
+ video: true,
+ microphoneId: meetingInfo.microphoneId || "",
+ cameraId: "", //移动端使用前置后置摄像头选择
+ });
+ console.log("开始播放本地流");
+ //默认小窗口渲染本端视频
+ const view = smallVideoRef.current?.getElement() as HTMLDivElement;
+ await rtcManagerRef.current?.playLocalStream(view);
+ //业务上实现本地镜像
+ const videoElement = view.querySelector("video");
+ if (videoElement) {
+ videoElement.style.transform += "scaleX(-1)";
+ console.log("开始发布本地流");
+ }
+ await rtcManagerRef.current?.publish();
+ };
+
+ const initRTCManagerEvents = () => {
+ if (!rtcManagerRef.current) {
+ return;
+ }
+ rtcManagerRef.current.on(
+ "DeviceError",
+ (result: {
+ mediaType: "audio" | "video";
+ reason: string;
+ message: string;
+ }) => {
+ console.error("检测到设备异常: ", JSON.stringify(result, null, " "));
+ Toast.show({
+ content: result.message,
+ icon: "fail",
+ });
+ },
+ );
+
+ rtcManagerRef.current.on("peer-online", (result: { uid: string }) => {
+ console.warn("[RTCManager通知]: 有人加入房间 ", result.uid);
+ Toast.show({
+ content: `${result.uid}加入房间`,
+ });
+ });
+
+ rtcManagerRef.current.on(
+ "peer-leave",
+ (result: { uid: string; reason: PEER_LEAVE_REASON_CODE }) => {
+ console.warn(
+ "[RTCManager通知]: 有人离开房间 ",
+ result.uid,
+ "原因: ",
+ result.reason,
+ );
+ Toast.show({
+ content: `${result.uid}离开房间,原因: ${result.reason}`,
+ });
+ },
+ );
+
+ rtcManagerRef.current.on(
+ "stream-removed",
+ (result: { uid: string; mediaType: string }) => {
+ console.warn(
+ "[RTCManager通知]: ",
+ result.uid,
+ "停止发布: ",
+ result.mediaType,
+ );
+ Toast.show({
+ content: `${result.uid}关闭了${result.mediaType}`,
+ });
+ },
+ );
+
+ rtcManagerRef.current?.on(
+ "stream-need-play",
+ (evt: { uid: string; mediaType: "audio" | "video" }) => {
+ console.log(
+ "[RTCManager通知]: 收到媒体需要播放的通知: ",
+ JSON.stringify(evt, null, " "),
+ );
+ remoteUid.current = evt.uid;
+ if (evt.mediaType === "audio") {
+ setIsMuted(true);
+ Toast.show({
+ content: "请点击对端窗口的静音图标播放对方声音",
+ duration: 5000,
+ });
+ }
+ rtcManagerRef.current?.playRemoteVideoStream(
+ largeVideoRef.current as HTMLDivElement,
+ evt.uid,
+ );
+ },
+ );
+ rtcManagerRef.current?.on("media-info", (evt: Partial) => {
+ // console.log(
+ // "[RTCManager通知]: media-info: ",
+ // JSON.stringify(evt, null, " "),
+ // );
+ setMediaInfo((prevState: MediaInfo) => ({ ...prevState, ...evt }));
+ });
+ rtcManagerRef.current?.on("network-quality", handleNetworkQuality);
+ };
+
+ const initNERTCEvents = () => {
+ const nertClient = rtcManagerRef.current?.nertClient;
+ //用于注册NERTC的原生事件
+
+ nertClient?.on("error", (type: string) => {
+ console.error("[NERTC通知]: ERROR: ", type);
+ if (type === "SOCKET_ERROR") {
+ console.error("[NERTC通知]: 网络断开已经退出房间");
+ Toast.show({
+ content: "网络断开,请检查网络",
+ icon: "fail",
+ duration: 5000,
+ });
+ rtcManagerRef.current?.destroy();
+ }
+ });
+ //@ts-ignore
+ nertClient?.on("mute-audio", (result: { uid: string }) => {
+ console.warn(`${result.uid} mute自己的音频`);
+ Toast.show({
+ content: `${result.uid}禁音了`,
+ });
+ });
+ //@ts-ignore
+ nertClient?.on("unmute-audio", (result: { uid: string }) => {
+ console.warn(`${result.uid} unmute自己的音频`);
+ Toast.show({
+ content: `${result.uid}恢复声音了`,
+ });
+ });
+
+ nertClient?.on(
+ "connection-state-change",
+ (data: { prevState: string; curState: string; reconnect: boolean }) => {
+ console.log("[NERTC通知]: change: ", data);
+ if (data.reconnect) {
+ let content = "网络异常,正在重连";
+ if (data.curState === "CONNECTED") {
+ content = "网络恢复,重连成功";
+ }
+ Toast.show({
+ content,
+ });
+ }
+ },
+ );
+
+ nertClient?.on("TrackEnded", (mediaType: string) => {
+ console.log("[NERTC通知]: TrackEnded: ", mediaType);
+ resume(mediaType);
+ Toast.show({
+ content: `检测到${mediaType}异常,正在恢复`,
+ });
+ });
+
+ nertClient?.on("TrackMuted", (mediaType: string) => {
+ console.log("[NERTC通知]: TrackMuted: ", mediaType);
+ resume(mediaType);
+ Toast.show({
+ content: `检测到${mediaType}异常,正在恢复`,
+ });
+ });
+
+ nertClient?.on("local-track-state", (trackStates: any) => {
+ console.log("[NERTC通知]: local-track-state: ", trackStates);
+ for (let mediaType in trackStates) {
+ const trackState = trackStates[mediaType];
+ console.warn(`${mediaType} state: ${trackState.muted}`);
+ if (trackState.muted) {
+ console.warn("尝试恢复播放", mediaType);
+ resume(mediaType);
+ Toast.show({
+ content: `检测到${mediaType}异常,正在恢复`,
+ });
+ }
+ }
+ });
+
+ nertClient?.on("recording-device-changed", (evt: any) => {
+ console.log(
+ `[NERTC通知]:麦克风设备变化 【${evt.state}】recording-device-changed ${evt.device.label}`,
+ evt,
+ );
+ if (evt.state === "ACTIVE") {
+ Toast.show({
+ content: `检测到麦克风新设备: ${evt.device.label}`,
+ });
+ } else if (evt.state === "INACTIVE") {
+ Toast.show({
+ content: `检测到麦克风拔出: ${evt.device.label}`,
+ });
+ }
+ });
+
+ nertClient?.on("playout-device-changed", (evt: any) => {
+ console.log(
+ `[NERTC通知]:扬声器设备变化 【${evt.state}playout-device-changed ${evt.device.label}`,
+ evt,
+ );
+ if (evt.state === "ACTIVE") {
+ Toast.show({
+ content: `检测到扬声器新设备: ${evt.device.label}`,
+ });
+ } else if (evt.state === "INACTIVE") {
+ Toast.show({
+ content: `检测到扬声器拔出: ${evt.device.label}`,
+ });
+ }
+ });
+
+ nertClient?.on(
+ "mute-video",
+ (evt: {
+ /**
+ * 远端用户 ID。
+ */
+ uid: number | string;
+ }) => {
+ console.warn(`[NERTC通知]: ${evt.uid} mute自己的视频`);
+ if (evt.uid.toString() === remoteUid.current) {
+ console.log("[NERTC通知]: 对端mute自己的视频");
+ Toast.show({
+ content: "对端推到后台, mute自己的视频",
+ duration: 1500,
+ });
+ }
+ },
+ );
+ nertClient?.on(
+ "unmute-video",
+ (evt: {
+ /**
+ * 远端用户 ID。
+ */
+ uid: number | string;
+ }) => {
+ console.warn(`[NERTC通知]: ${evt.uid} resume自己的视频`);
+ if (evt.uid.toString() === remoteUid.current) {
+ console.log("对端resume自己的视频");
+ Toast.show({
+ content: "对端回到前台, resume自己的视频",
+ duration: 1500,
+ });
+ }
+ },
+ );
+ };
+
+ const resume = (mediaType: string) => {
+ console.log("[NERTC通知]: 尝试恢复本地流: ", mediaType);
+ if (mediaType === "audio") {
+ openTheMicrophone(true);
+ openTheMicrophone(true);
+ } else if (mediaType === "video") {
+ openTheCamera();
+ openTheCamera();
+ }
+ };
+ const openTheMicrophone = async (realClose = false) => {
+ try {
+ console.log("麦克风 muted: ", muted);
+ if (muted) {
+ console.log("开始打开麦克风");
+ //直接调用NERTC SDK的接口
+ if (realClose) {
+ await rtcManagerRef.current?.nertLocalStream?.open({ type: "audio" });
+ } else {
+ await rtcManagerRef.current?.nertLocalStream?.unmuteAudio();
+ }
+ setMuted(false);
+ } else {
+ console.log("开始关闭麦克风");
+ if (realClose) {
+ await rtcManagerRef.current?.nertLocalStream?.close({
+ type: "audio",
+ });
+ } else {
+ //mute并非真的关闭设备,仅仅发送静音包
+ await rtcManagerRef.current?.nertLocalStream?.muteAudio();
+ }
+ setMuted(true);
+ }
+ } catch (error) {
+ const message = "操作麦克风失败: " + (error as Error).message;
+ console.error(message);
+ Toast.show({
+ content: message,
+ icon: "fail",
+ });
+ }
+ };
+
+ const openTheCamera = async () => {
+ try {
+ console.log("摄像头 cameraOpened: ", cameraOpened);
+ if (cameraOpened) {
+ console.log("开始关闭摄像头");
+ //直接调用NERTC SDK的接口
+ await rtcManagerRef.current?.nertLocalStream?.close({ type: "video" });
+ setCameraOpened(false);
+ } else {
+ console.log("开始打开摄像头 facingMode: ", facingMode);
+ await rtcManagerRef.current?.nertLocalStream?.open({
+ type: "video",
+ facingMode,
+ });
+ console.log("开始播放本地流");
+ await rtcManagerRef.current?.playLocalStream(
+ rtcManagerRef.current.localView as HTMLElement,
+ );
+ //业务上实现本地镜像
+ const videoElement =
+ rtcManagerRef.current?.localView?.querySelector("video");
+ videoElement && (videoElement.style.transform += " scaleX(-1)");
+ setCameraOpened(true);
+ }
+ } catch (error) {
+ const message = "操作摄像头失败: " + (error as Error).message;
+ console.error(message);
+ Toast.show({
+ content: message,
+ icon: "fail",
+ });
+ }
+ };
+
+ const flipCamera = async () => {
+ try {
+ console.log("切换摄像头 facingMode: ", facingMode);
+ const newFacingMode = facingMode === "user" ? "environment" : "user";
+ const localStream = rtcManagerRef.current?.nertLocalStream;
+ if (!localStream) {
+ return;
+ }
+ await localStream.close({
+ type: "video",
+ });
+ const USER_AGENT = (window.navigator && window.navigator.userAgent) || "";
+ const IS_IPAD = /iPad/i.test(USER_AGENT);
+ const IS_IPHONE = /iPhone/i.test(USER_AGENT) && !IS_IPAD;
+ const IS_IPOD = /iPod/i.test(USER_AGENT);
+ const IS_IOS = IS_IPHONE || IS_IPAD || IS_IPOD;
+ if (IS_IOS) {
+ //苹果手机直接遵守facingMode的设置,所以直接利用该属性完成切换,无需对deviceId进行处理
+ await localStream.open({
+ type: "video",
+ facingMode: newFacingMode,
+ });
+ console.log("打开摄像头 sucess");
+ //考虑到有可能大小屏切换了,所以建议使用rtcManager中记录的localView
+ await rtcManagerRef.current?.playLocalStream(
+ rtcManagerRef.current.localView as HTMLElement,
+ );
+ setFacingMode(newFacingMode);
+ } else {
+ //facingMode参数目前实际测试看,安卓手机前置支持情况还可以,后置表现却是各有千秋,表现诸如选错摄像头、黑屏等问题
+ if (newFacingMode === "user") {
+ await localStream.open({
+ type: "video",
+ facingMode: "user",
+ });
+ console.log("打开摄像头 sucess");
+ await rtcManagerRef.current?.playLocalStream(
+ rtcManagerRef.current.localView as HTMLElement,
+ );
+ } else {
+ //安卓手机利用deviceId来指定单一的后置摄像头
+ const cameraList = (await rtcManagerRef.current?.getCameras()) || [];
+ //NERTC.getCameras经根据排列组合,帮你处理好每个厂商暴露出来给我们使用的设备序列。有的像华为它会把闪光灯也包括在内,并暴露多个前后置给你使用,后置我们取最后一个设备
+ const cameraDevice = cameraList[cameraList.length - 1];
+ if (
+ cameraDevice &&
+ cameraDevice.label &&
+ cameraDevice.label.includes("back")
+ ) {
+ console.log(
+ "摄像头列表中的最后一位确认是后置摄像头,且当前已经有了摄像头权限",
+ );
+ } else {
+ console.warn(
+ "当前环境不是H5,或者当前环境还没有摄像头权限, 打印cameraDevice: ",
+ JSON.stringify(cameraDevice, null, ""),
+ );
+ }
+ await localStream.open({
+ type: "video",
+ deviceId: cameraDevice.deviceId, //使用deviceId指定后置摄像头
+ });
+ console.log("打开摄像头 sucess");
+ await rtcManagerRef.current?.playLocalStream(
+ rtcManagerRef.current.localView as HTMLElement,
+ );
+ }
+ setFacingMode(newFacingMode);
+ }
+ } catch (error) {
+ const message = "切换摄像头失败: " + (error as Error).message;
+ console.error(message);
+ Toast.show({
+ content: message,
+ icon: "fail",
+ });
+ }
+ };
+
+ const swapVideos = () => {
+ console.log("swapVideos() 点击大小屏幕切换");
+ const result = rtcManagerRef.current?.swapVideos();
+ if (result) {
+ // 大小屏幕切换后,记得刷新下网络状态图标
+ // 前置比较:只有值变化时才更新状态
+ let tempLocalSignalLevel = localSignalLevel;
+ setLocalSignalLevel((prev: SignalLevel) =>
+ prev !== remoteSignalLevel ? remoteSignalLevel : prev,
+ );
+ setRemoteSignalLevel((prev: SignalLevel) =>
+ prev !== tempLocalSignalLevel ? tempLocalSignalLevel : prev,
+ );
+ }
+ };
+ const leave = async () => {
+ console.log("开始离开房间");
+ setMeetingInfo({ nickName: "", channelName: "" });
+ rtcManagerRef.current?.leaveChannel();
+ rtcManagerRef.current?.destroy();
+ rtcManagerRef.current = null;
+ navigate("/");
+ };
+
+ // 转换NERTC SDK信号值为组件信号值
+ const convertNetworkLevel = (sdkLevel: number): SignalLevel => {
+ switch (sdkLevel) {
+ case 0:
+ return 0; // SDK 0 → 组件1(unkonwn)
+ case 1:
+ case 2:
+ return 3; // SDK 1-2 → 组件3(很好)
+ case 3:
+ return 2; // SDK 3 → 组件2(一般)
+ case 4:
+ case 5:
+ case 6:
+ return 1; // SDK 4-6 → 组件1(极差)
+ }
+ return 0; // 默认unkonwn
+ };
+
+ // 防抖处理(1000ms间隔)
+ const handleNetworkQuality = useCallback(
+ throttle((evt: { local: number; remote: number }) => {
+ //console.log("[RTCManager通知]: 网络质量: ", evt);
+ let { local, remote } = evt;
+ if (isLocalVideoLarge) {
+ //此时进行了大小屏切换,右上角展示的是远端视频画面
+ local = remote;
+ remote = evt.local;
+ }
+ const newLocal = convertNetworkLevel(local);
+ const newRemote = convertNetworkLevel(remote);
+
+ // 前置比较:只有值变化时才更新状态
+ setLocalSignalLevel((prev: SignalLevel) =>
+ prev !== newLocal ? newLocal : prev,
+ );
+ setRemoteSignalLevel((prev: SignalLevel) =>
+ prev !== newRemote ? newRemote : prev,
+ );
+ }, 1000),
+ [],
+ );
+
+ return (
+
+ {/*右上角小视频窗口,支持拖拽*/}
+
+
+
+ {isMuted && isLocalVideoLarge && (
+ {
+ e.stopPropagation(); // 阻止事件冒泡
+ console.log("点击播放声音");
+ rtcManagerRef.current?.playRemoteAudioStream(remoteUid.current);
+ setIsMuted(false);
+ }}
+ />
+ )}
+
+
+ {/** 视频渲染大窗口UI */}
+
+
+
+ {isMuted && !isLocalVideoLarge && (
+ {
+ e.stopPropagation(); // 阻止事件冒泡
+ console.log("点击播放声音");
+ rtcManagerRef.current?.playRemoteAudioStream(remoteUid.current);
+ setIsMuted(false);
+ }}
+ />
+ )}
+
+
+
{`房间名称: ${meetingInfo.channelName}`}
+
{`音频音量: ${meidaInfo.audioLevel}`}
+
{`音频码率: ${meidaInfo.audioBitrate} Kbps`}
+
{`视频码率: ${meidaInfo.videoBitrate} Kbps`}
+
{`视频帧率: ${meidaInfo.videoFrameRate} fps`}
+
+ {`视频分辨率: ${meidaInfo.videoFrameWidth} * ${meidaInfo.videoFrameHeight}`}
+
+
+ {/*
*/}
+ {!largeVideoRef.current?.querySelector("video") && "等待视频流"}
+
+ {/** 按钮UI */}
+
+

openTheMicrophone()}
+ />
+

openTheCamera()}
+ />
+

flipCamera()}
+ />
+

leave()}
+ />
+
+
+ );
+};
+export default RTC;
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/routes/router.tsx b/One-to-One-Video/NERtcSample-1to1-Web-React/src/routes/router.tsx
new file mode 100644
index 0000000..b60058b
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/routes/router.tsx
@@ -0,0 +1,37 @@
+import { createBrowserRouter, createHashRouter } from "react-router-dom";
+//import Home from '../pages/home'
+import Preview from "../pages/preview";
+import RTC from "../pages/rtc";
+import App from "../App";
+import Loading from "../components/loading";
+import { lazy, Suspense } from "react";
+const Home = lazy(() => import("../pages/home"));
+
+// 路由配置
+const routes = [
+ {
+ path: "/",
+ element: ,
+ children: [
+ {
+ index: true,
+ element: (
+ }>
+
+
+ ),
+ },
+ { path: "home", element: },
+ { path: "preview", element: },
+ { path: "rtc", element: },
+ ],
+ },
+];
+
+//@ts-ignore
+const isDev = import.meta.env.MODE === "development";
+// 或 import.meta.env.DEV
+console.log("isDev", isDev);
+const router = isDev ? createBrowserRouter(routes) : createHashRouter(routes);
+
+export default router;
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/store/index.tsx b/One-to-One-Video/NERtcSample-1to1-Web-React/src/store/index.tsx
new file mode 100644
index 0000000..c56555a
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/store/index.tsx
@@ -0,0 +1,78 @@
+import { createContext, useState, useContext, type ReactNode } from "react";
+import type { Client } from "nertc-web-sdk/types/client";
+import type { Stream } from "nertc-web-sdk/types/stream";
+
+type MeetingInfo = {
+ nickName?: string;
+ channelName?: string;
+ microphoneId?: string;
+ cameraId?: string;
+};
+
+// 定义Context的类型
+type AppContextType = {
+ meetingInfo: MeetingInfo;
+ setMeetingInfo: (info: MeetingInfo) => void;
+};
+
+// 创建Context并设置默认值(可选)
+const AppContext = createContext(undefined);
+
+// 封装Provider组件
+export function AppProvider({ children }: { children: ReactNode }) {
+ const [meetingInfo, setMeetingInfo] = useState({
+ nickName: "",
+ channelName: "",
+ microphoneId: "",
+ cameraId: "",
+ });
+
+ return (
+
+ {children}
+
+ );
+}
+
+// 自定义Hook(方便在组件中使用)
+export function useAppContext() {
+ const context = useContext(AppContext);
+ if (context === undefined) {
+ throw new Error("useAppContext must be used within an AppProvider");
+ }
+ return context;
+}
+
+type RTCInfo = {
+ client?: Client | undefined;
+ localStream?: Stream | undefined;
+};
+
+type RTCContextType = {
+ rtcInfo: RTCInfo;
+ setRTCInfo: (info: RTCInfo) => void;
+};
+
+const RTCContext = createContext(undefined);
+
+export function RTCProvider({ children }: { children: ReactNode }) {
+ const [rtcInfo, setRTCInfo] = useState({
+ client: undefined,
+ localStream: undefined,
+ });
+
+ return (
+
+ {children}
+
+ );
+}
+
+// 自定义Hook(方便在组件中使用)
+export function useRTCContext() {
+ const context = useContext(RTCContext);
+ if (context === undefined) {
+ throw new Error("useRTCContext must be used within an useRTCContext");
+ }
+ return context;
+}
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/types/index.ts b/One-to-One-Video/NERtcSample-1to1-Web-React/src/types/index.ts
new file mode 100644
index 0000000..282f8e7
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/types/index.ts
@@ -0,0 +1,57 @@
+//因为Typescript对不同平台的timer返回值类型处理不同:
+export type Timer =
+ | ReturnType
+ | ReturnType;
+export interface LoginResponse {
+ code: number;
+ requestId: string;
+ message: string;
+ data: unknown;
+ success: boolean;
+}
+
+export interface consultationResponse {}
+
+export type NetworkStatus =
+ | 0 // UNKNOWN
+ | 1 // EXCELLENT
+ | 2 // GOOD
+ | 3 // POOR
+ | 4 // BAD
+ | 5 // VERYBAD
+ | 6; // DOWN
+
+export type NetworkStatusText =
+ | "UNKNOWN"
+ | "EXCELLENT"
+ | "GOOD"
+ | "POOR"
+ | "BAD"
+ | "VERYBAD"
+ | "DOWN";
+
+export interface NetworkInfo {
+ osType: string;
+ userAgent: string;
+ supportRTC: boolean;
+ uplinkNetworkQuality: NetworkStatusText;
+ downlinkNetworkQuality: NetworkStatusText;
+}
+
+export interface DeviceCheckResult {
+ microphone: { isOK: boolean };
+ speaker: { isOK: boolean };
+ camera: { isOK: boolean };
+ network: NetworkInfo; // 或更具体的网络类型
+}
+
+export interface MediaInfo {
+ audioLevel: number;
+ audioBitrate: number;
+ videoBitrate: number;
+ videoFrameRate: number;
+ videoFrameWidth: number;
+ videoFrameHeight: number;
+ uplinkNetworkQuality: NetworkStatus;
+ downlinkNetworkQuality: NetworkStatus;
+}
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/src/utils/index.tsx b/One-to-One-Video/NERtcSample-1to1-Web-React/src/utils/index.tsx
new file mode 100644
index 0000000..156bb10
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/src/utils/index.tsx
@@ -0,0 +1,239 @@
+import { sha1 } from "js-sha1";
+
+/* 生成一个随机数 */
+export const getRandomInt = (min: number, max: number): number => {
+ min = Math.ceil(min);
+ max = Math.floor(max);
+ return Math.floor(Math.random() * (max - min + 1)) + min;
+};
+
+/* 判断是否为微信浏览器 */
+export const isWeixinBrowser = (): boolean => {
+ const ua = navigator.userAgent.toLowerCase();
+ return ua.includes("micromessenger");
+};
+
+/* 判断是否为chrome浏览器 */
+export const checkChrome = () => {
+ // 排除 Firefox/Safari 等非 Chromium 内核
+ if (
+ navigator.userAgent.includes("Firefox") ||
+ (navigator.userAgent.includes("Safari") &&
+ !navigator.userAgent.includes("Chrome"))
+ ) {
+ return false;
+ }
+
+ // 核心检测逻辑
+ const isChromium = "chrome" in window;
+ const isEdge = navigator.userAgent.includes("Edg/");
+ const isOpera = navigator.userAgent.includes("OPR/");
+
+ // 包含 Chrome、Edge、Opera、Vivaldi 等 Chromium 系浏览器
+ return (
+ (isChromium || navigator.userAgent.includes("Chrome")) &&
+ !isEdge &&
+ !isOpera
+ );
+};
+
+type winVersion =
+ | "10.0"
+ | "6.3"
+ | "6.2"
+ | "6.1"
+ | "6.0"
+ | "5.2"
+ | "5.1"
+ | "5.0";
+
+/* 获取操作系统和浏览器信息 */
+export const getSystemInfo = () => {
+ const userAgent = navigator.userAgent.toLowerCase();
+ let os = undefined;
+ let osVersion = undefined;
+ let browser = undefined;
+ let browserVersion = undefined;
+ const isMobile = /mobile|android|iphone|ipad|ipod/i.test(userAgent);
+ const isWeChat = /micromessenger/i.test(userAgent);
+
+ console.warn("userAgent: ", navigator.userAgent);
+
+ // 检测操作系统及版本
+ if (/harmonyos/i.test(userAgent)) {
+ os = "Harmony";
+ osVersion = userAgent.match(/harmonyos (\d+\.\d+)/i)?.[1] || osVersion;
+ } else if (/android/i.test(userAgent)) {
+ os = "Android";
+ osVersion = userAgent.match(/android (\d+\.\d+)/i)?.[1] || osVersion;
+ } else if (/iphone|ipad|ipod/i.test(userAgent)) {
+ os = "iOS";
+ osVersion =
+ userAgent.match(/os (\d+_\d+)/i)?.[1]?.replace("_", ".") ||
+ userAgent.match(/cpu (iphone )?os (\d+_\d+)/i)?.[2]?.replace("_", ".") ||
+ osVersion;
+ } else if (/windows nt/i.test(userAgent)) {
+ os = "Windows";
+ // Windows版本映射
+ const winVersionMap = {
+ "10.0": "10/11",
+ "6.3": "8.1",
+ "6.2": "8",
+ "6.1": "7",
+ "6.0": "Vista",
+ "5.2": "XP",
+ "5.1": "XP",
+ "5.0": "2000",
+ };
+ const ntVersion = userAgent.match(
+ /windows nt (\d+\.\d+)/i,
+ )?.[1] as winVersion;
+ osVersion = winVersionMap[ntVersion] || ntVersion || osVersion;
+ } else if (/linux/i.test(userAgent)) {
+ os = "Linux";
+ // Linux发行版检测
+ if (/ubuntu/i.test(userAgent)) {
+ osVersion = userAgent.match(/ubuntu[\/ ](\d+\.\d+)/i)?.[1] || osVersion;
+ } else if (/debian/i.test(userAgent)) {
+ osVersion = userAgent.match(/debian[\/ ](\d+)/i)?.[1] || osVersion;
+ } else if (/fedora/i.test(userAgent)) {
+ osVersion = userAgent.match(/fedora[\/ ](\d+)/i)?.[1] || osVersion;
+ }
+ } else if (/macintosh|mac os x/i.test(userAgent)) {
+ os = "MacOS";
+ osVersion =
+ userAgent.match(/mac os x (\d+[._]\d+)/i)?.[1]?.replace("_", ".") ||
+ osVersion;
+ }
+
+ // 检测浏览器及版本
+ if (isWeChat) {
+ browser = "WeChat";
+ // 微信内置浏览器版本检测
+ browserVersion =
+ userAgent.match(/micromessenger\/(\d+\.\d+\.\d+)/i)?.[1] ||
+ browserVersion;
+ } else if (/chrome|crios/i.test(userAgent) && !/edge/i.test(userAgent)) {
+ browser = "Chrome";
+ browserVersion =
+ userAgent.match(/(chrome|crios)\/(\d+\.\d+)/i)?.[2] || browserVersion;
+ } else if (/firefox|fxios/i.test(userAgent)) {
+ browser = "Firefox";
+ browserVersion =
+ userAgent.match(/(firefox|fxios)\/(\d+\.\d+)/i)?.[2] || browserVersion;
+ } else if (/safari/i.test(userAgent) && !/chrome/i.test(userAgent)) {
+ browser = "Safari";
+ browserVersion =
+ userAgent.match(/version\/(\d+\.\d+)/i)?.[1] || browserVersion;
+ } else if (/edge/i.test(userAgent)) {
+ browser = "Edge";
+ browserVersion =
+ userAgent.match(/edge\/(\d+\.\d+)/i)?.[1] || browserVersion;
+ } else if (/trident/i.test(userAgent)) {
+ browser = "IE";
+ browserVersion = userAgent.match(/rv:(\d+\.\d+)/i)?.[1] || browserVersion;
+ } else if (/opera|opr/i.test(userAgent)) {
+ browser = "Opera";
+ browserVersion =
+ userAgent.match(/(opera|opr)\/(\d+\.\d+)/i)?.[2] || browserVersion;
+ }
+
+ return {
+ os,
+ osVersion,
+ browser,
+ browserVersion,
+ isMobile,
+ isWeChat,
+ userAgent: navigator.userAgent, // 返回原始UA
+ };
+};
+/* 从 location.hash 提取查询参数 */
+export const getHashSearch = (hash: string) => {
+ const searchIndex = hash.indexOf("?");
+ const search = searchIndex !== -1 ? hash.substring(searchIndex) : "";
+
+ return search;
+};
+
+/** 云信基础Token鉴权(https://doc.yunxin.163.com/nertc/server-apis/TcxNDAxMTI?platform=server)
+ 这里仅用于展示,实际项目中不要使用明文的 secret,建议使用应用服务器生成 token
+*/
+export const getAppToken = async ({
+ appkey,
+ secret,
+ uid,
+ channelName,
+}: {
+ appkey: string;
+ secret: string;
+ uid: string;
+ channelName: string;
+}) => {
+ const Nonce = Math.ceil(Math.random() * 1e9);
+ const CurTime = Math.ceil(Date.now() / 1000);
+ const CheckSum = sha1(`${secret}${Nonce}${CurTime}`);
+
+ const headers = new Headers({
+ "Content-Type": "application/x-www-form-urlencoded;charset=utf-8",
+ });
+ headers.append("AppKey", appkey);
+ headers.append("Nonce", Nonce.toString());
+ headers.append("CurTime", CurTime.toString());
+ headers.append("CheckSum", CheckSum);
+
+ const data = await fetch(
+ "https://api.netease.im/nimserver/user/getToken.action",
+ {
+ method: "POST",
+ headers: headers,
+ body: `uid=${encodeURIComponent(uid)}&channelName=${encodeURIComponent(channelName)}`,
+ },
+ );
+
+ const result = await data.json();
+ console.log("getAppToken result: ", result);
+ if (result?.token) {
+ console.log("getAppToken token: ", result);
+ return result.token;
+ } else {
+ console.error(result || data);
+ return null;
+ }
+};
+
+// 这里仅用于展示,实际项目中不要使用明文的 secret,建议使用应用服务器生成 token
+export const ajax = async ({
+ url,
+ appkey,
+ secret,
+ headers,
+ body,
+}: {
+ url: string;
+ appkey: string;
+ secret: string;
+ headers?: Headers;
+ body: string;
+}) => {
+ console.log("ajax 请求: ", url, appkey, secret, body);
+ const Nonce = Math.ceil(Math.random() * 1e9);
+ const CurTime = Math.ceil(Date.now() / 1000);
+ const CheckSum = sha1(`${secret}${Nonce}${CurTime}`);
+
+ const httpHeaders = new Headers(headers || {});
+ httpHeaders.append("Content-Type", "application/json");
+ httpHeaders.append("AppKey", appkey);
+ httpHeaders.append("Nonce", Nonce.toString());
+ httpHeaders.append("CurTime", CurTime.toString());
+ httpHeaders.append("CheckSum", CheckSum);
+ httpHeaders;
+ const data = await fetch(url, {
+ method: "POST",
+ headers: httpHeaders,
+ body,
+ });
+
+ const result = await data.json();
+ return result;
+};
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/ssh/server.crt b/One-to-One-Video/NERtcSample-1to1-Web-React/ssh/server.crt
new file mode 100644
index 0000000..0c5f19b
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/ssh/server.crt
@@ -0,0 +1,21 @@
+-----BEGIN CERTIFICATE-----
+MIIDazCCAlOgAwIBAgIUJk4Utgaq81W3AWBXFQEIKW2dQ1cwDQYJKoZIhvcNAQEL
+BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
+GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yNTA0MjMwOTU4MTlaFw0zNTA0
+MjEwOTU4MTlaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
+HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggEiMA0GCSqGSIb3DQEB
+AQUAA4IBDwAwggEKAoIBAQDCPBKM4wk4nx+bdckhm5u8cQnCSoLrebsjrGb46Ran
+UIkkdPZZ+X4jbJAgGtZcFFkqtqzULIsyQh6bTviSTBu+Ab1ozb8eg3Wl1ALEm2J/
+R9FCpt2ABS3VOkalSxIqo/vHT+iU0NVyGe3F8Bqgd4uHCJNr/4HgllDTYujnDW9b
+m89gpareH6rVGnLBGgjQuvCr6U3eo+QCZNjsEpn8xw9iSr4RX81p32TrjsTEL42+
+fiVd3lmnVLn57M/paooXV4lxa8jxojVuplho2G7WtIOwqwJVKed7MBtZYrMWuLCF
+CrlMGdi3hVf1TGyKoZ6Kiq53zhtP4at/Ck/Yp3vcE54NAgMBAAGjUzBRMB0GA1Ud
+DgQWBBTEQ/6KPAI2SLzd8O/T5uhBWc2ALTAfBgNVHSMEGDAWgBTEQ/6KPAI2SLzd
+8O/T5uhBWc2ALTAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCr
+4nQ8Ey7f7+W4NLENhMsGOTeuDWsQqoTPYCoBC+/7vp39qdYwGJisOrXQCURM3IDD
+zgA2TwQahYNSEJ6rr1qkT+4lSPzOFT0b8Q2QFYRgNUvkRmAQKw16pD7LOqCAeznI
+vWMUpk6Or3MfomGAmxhWbgExdK2Byxduh03NKWZ71Ste3njwJSsmdaTtZcSgKhhh
+uJQrO8PjrYm3uFXDPPq6ffQlIYBpFjkTAZB1eiRBtU1OTFiCzVhrPBv/ouP6Ser3
+qKL2ZLLnMr9vrpbVQJkDZObkehuHxIC8ZhNG9a0t5CmXqZw83OABCvkewvbDLf33
+rkFXUuUyeRJjtBzFNTbk
+-----END CERTIFICATE-----
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/ssh/server.key b/One-to-One-Video/NERtcSample-1to1-Web-React/ssh/server.key
new file mode 100644
index 0000000..9c7b977
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/ssh/server.key
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDCPBKM4wk4nx+b
+dckhm5u8cQnCSoLrebsjrGb46RanUIkkdPZZ+X4jbJAgGtZcFFkqtqzULIsyQh6b
+TviSTBu+Ab1ozb8eg3Wl1ALEm2J/R9FCpt2ABS3VOkalSxIqo/vHT+iU0NVyGe3F
+8Bqgd4uHCJNr/4HgllDTYujnDW9bm89gpareH6rVGnLBGgjQuvCr6U3eo+QCZNjs
+Epn8xw9iSr4RX81p32TrjsTEL42+fiVd3lmnVLn57M/paooXV4lxa8jxojVuplho
+2G7WtIOwqwJVKed7MBtZYrMWuLCFCrlMGdi3hVf1TGyKoZ6Kiq53zhtP4at/Ck/Y
+p3vcE54NAgMBAAECggEAHPo8BOt/iK7/VtYaYk/bdXWicGtlX3QTHfNZOXFXgTFc
+BHsJSyBprSdD0/ECYfe3MHl9t08d79WQHg4zZMAYs3QsVI0SzPPtLVw/6CJhGNQX
+aCDfktCIUfNrxA/lxJl1CqXlRlkRlFWXSkcpRx9j9YqrU8VpaZ7YrXC9hnBUA2Us
+3vKZHWSfhBmL+B0CM6CqK47QcNlLjezZwgvNn5XfJTrFK/EU/adtl9QMPCUVQw8a
+/gQvumZ2vrBhOLShaaQidEUU1GeP31WyDMC9yQwZao4CHaX+PZN5+U7GeAc1bEwp
+U0vegX4Ce62LmQ0qUb/s/Lkrmb1pO3jqTh40/2UvkQKBgQDiNSqZk1KNI+axJoY5
+oG/xeyzVSzPJFvT8ANwzq5BiHJWDltuyFc4Sy5/48tHMKloZ6KgBWk8jwUxrMzHt
+rY83YslGa7OKh0S80ehMnGbcQUi4TpOD2b9XtXBsTiWUjo/6A8QwZDKmXrIao/lQ
+oomKY1im/Pza5fwRyRzbzvzeqwKBgQDb0ObD7iwwWbkc/c2GoKpKc7Q7a9ayc9KH
+YbGA3Yhmq+lAljYFpaSYAB8Bk0Kyzsf3bLcwgTnIf08E2oPAzOxYijsTXROYiOgm
+9XAenroXiJrB0DGa3t+MLoWs29YZiDCovpSotXGFPhGPEPvujwc2q5m31a15MX0/
+KxKXLUEWJwKBgDJRRUKYJLrEi6JIQX88EuqSTay8Z66JbsFHp7POq/VHCnMU8ZWx
+h/9iUBleWhCiMxykRgrW/dekPc1yu950xvC5BrClcHCWGlIuFxBDkhXYZ8ano+Sm
+YQuvjmxpDa7370rb354sC6A6XD/UzbaEETg5VRUAHXbLxcBnDgZPCqy1AoGAZLuh
+1O4DoBCt7SQ+GFDuWPoXARgVJmg/dT0GzAg5ZtunI6ryjLnw6Js9mkoyyaBLMQ7I
+EFlX8pKs0ouUzzjZomWOVvxTa5Zp1NirDc0teHVofiL7aH50MVhsd9+yiLbJnbrg
+g0PLBwV/pHFjElrHMn2HDyDDw4MzY0xI07CwYAcCgYAGqdJAHOt1HpKTm29dfR6K
+0LtnFIYjF7gO32IyvfzxPLE99vwMRS2a1fYjaHqBEtgt5XcMiZXG9IldWLux0q+n
++u4gdefG9PdtUeBWSfOxyse0T0GxlHOoY96ztZ9FsIl0EkcyVAutuAjQ34lEhCdn
+S+Iu8T0SP5MisRXC363QAQ==
+-----END PRIVATE KEY-----
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/tsconfig.app.json b/One-to-One-Video/NERtcSample-1to1-Web-React/tsconfig.app.json
new file mode 100644
index 0000000..2a787d6
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/tsconfig.app.json
@@ -0,0 +1,32 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/tsconfig.json b/One-to-One-Video/NERtcSample-1to1-Web-React/tsconfig.json
new file mode 100644
index 0000000..77b6ce1
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/tsconfig.json
@@ -0,0 +1,23 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ],
+ "compilerOptions": {
+ "esModuleInterop": true,
+ "allowSyntheticDefaultImports": true,
+ "isolatedModules": false,
+ "preserveConstEnums": true,
+ "erasableSyntaxOnly": false,
+ "jsx": "react-jsx", // 或 "react"
+ "jsxImportSource": "react",
+ "module": "esnext",
+ "target": "es6",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "exclude": ["node_modules"]
+}
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/tsconfig.node.json b/One-to-One-Video/NERtcSample-1to1-Web-React/tsconfig.node.json
new file mode 100644
index 0000000..58a42d4
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/tsconfig.node.json
@@ -0,0 +1,30 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedSideEffectImports": true,
+
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/One-to-One-Video/NERtcSample-1to1-Web-React/vite.config.ts b/One-to-One-Video/NERtcSample-1to1-Web-React/vite.config.ts
new file mode 100644
index 0000000..af7a228
--- /dev/null
+++ b/One-to-One-Video/NERtcSample-1to1-Web-React/vite.config.ts
@@ -0,0 +1,47 @@
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import { fileURLToPath } from 'node:url';
+import path from 'node:path';
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+// https://vite.dev/config/
+export default defineConfig({
+ base: process.env.NODE_ENV === 'production' ? '/webdemo/nertcDemoH5/' : '/',
+ server: {
+ // proxy: {
+ // // 详细配置写法
+ // '/api': {
+ // target: 'http://1.95.20.209:38074',
+ // changeOrigin: true,
+ // rewrite: path => path.replace(/^\/api/, ''),
+ // },
+ // },
+ host: '0.0.0.0',
+ port: 5173,
+ open: true, // 自动打开浏览器
+ strictPort: true,
+ cors: true, // 允许跨域(如果前端需要访问后端API)
+ https: {
+ key: path.resolve(__dirname, './ssh/server.key'),
+ cert: path.resolve(__dirname, './ssh/server.crt'),
+ },
+ },
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ // 修复 React 运行时冲突
+ 'react/jsx-runtime': path.resolve(__dirname, 'node_modules/react/jsx-runtime.js'),
+ 'react/jsx-dev-runtime': path.resolve(__dirname, 'node_modules/react/jsx-dev-runtime.js'),
+ },
+ },
+ plugins: [
+ react({
+ jsxRuntime: 'automatic', // 显式指定 JSX 运行时模式
+ }),
+ ],
+ optimizeDeps: {
+ include: ['react', 'react-dom', 'react-router-dom', 'nertc-web-sdk'],
+ esbuildOptions: {
+ format: 'esm',
+ },
+ },
+});
diff --git a/README.md b/README.md
index 20964b9..c984bec 100644
--- a/README.md
+++ b/README.md
@@ -23,6 +23,7 @@
[NERtcSample-1to1-Android-Java](./One-to-One-Video/NERtcSample-1to1-Android-Java)|Android (Java)|Android 一对一视频通话指南 (Java)
[NERtcSample-1to1-iOS-Objective-C](./One-to-One-Video/NERtcSample-1to1-iOS-Objective-C)|IOS (Objective-C)|IOS 一对一视频通话指南 (Objective-C)
[NERtcSample-1to1-Web-Vue](./One-to-One-Video/NERtcSample-1to1-Web-Vue)|Web (Vue)|Web 一对一视频通话指南 (Vue)
+[NERtcSample-1to1-Web-React](./One-to-One-Video/NERtcSample-1to1-Web-React)|Web (React)|Web 一对一视频通话指南 (React)
[NERtcSample-1to1-Windows&MacOS-Qt](./One-to-One-Video/NERtcSample-1to1-Windows_macOS-Qt)|Win&MacOS (Qt)|Win&MacOS 一对一视频通话指南 (Qt)