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)}> + + +
+ +
+ +
+ +
+

自定义二维码

+
+
+ + setSize(parseInt(e.target.value))} + /> + {size}px +
+ +
+ + setBgColor(e.target.value)} + /> +
+ +
+ + setFgColor(e.target.value)} + /> +
+
+
+ +
+

使用说明

+
    +
  1. 下载二维码
  2. +
  3. 打开微信,点击右上角"+"
  4. +
  5. 选择"扫一扫"功能
  6. +
  7. 扫描二维码图片即可访问当前页面
  8. +
+
+
+ ); +}; + +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 ? ( +
+
+
+ 选择麦克风 +
+ +
+
+
+
+
+
+ 是否可以看到自己? +
+
+ + +
+
+
+ ) : 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)