Browse Source

[react demo] - 初始化

kevin.song 6 years ago
parent
commit
847f5eab0d

+ 29 - 0
examples/react/.gitignore

@@ -0,0 +1,29 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+yarn.lock
+package-lock.json
+
+# config
+src/config/index.js

+ 0 - 0
examples/react/README.md


+ 37 - 0
examples/react/package.json

@@ -0,0 +1,37 @@
+{
+  "name": "urtc-demo-react",
+  "version": "1.0.0",
+  "description": "UCloud RTC react 版本的 demo",
+  "dependencies": {
+    "@testing-library/jest-dom": "^4.2.4",
+    "@testing-library/react": "^9.3.2",
+    "@testing-library/user-event": "^7.1.2",
+    "normalize.css": "^8.0.1",
+    "react": "^16.12.0",
+    "react-dom": "^16.12.0",
+    "react-scripts": "3.3.0",
+    "unique-classnames": "^1.0.6",
+    "urtc-sdk": "^1.4.1"
+  },
+  "scripts": {
+    "start": "react-scripts start",
+    "build": "react-scripts build",
+    "test": "react-scripts test",
+    "eject": "react-scripts eject"
+  },
+  "eslintConfig": {
+    "extends": "react-app"
+  },
+  "browserslist": {
+    "production": [
+      ">0.2%",
+      "not dead",
+      "not op_mini all"
+    ],
+    "development": [
+      "last 1 chrome version",
+      "last 1 firefox version",
+      "last 1 safari version"
+    ]
+  }
+}

BIN
examples/react/public/favicon.ico


+ 43 - 0
examples/react/public/index.html

@@ -0,0 +1,43 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <meta name="theme-color" content="#000000" />
+    <meta
+      name="description"
+      content="Web site created using create-react-app"
+    />
+    <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
+    <!--
+      manifest.json provides metadata used when your web app is installed on a
+      user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
+    -->
+    <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
+    <!--
+      Notice the use of %PUBLIC_URL% in the tags above.
+      It will be replaced with the URL of the `public` folder during the build.
+      Only files inside the `public` folder can be referenced from the HTML.
+
+      Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
+      work correctly both with client-side routing and a non-root public URL.
+      Learn how to configure a non-root public URL by running `npm run build`.
+    -->
+    <title>React App</title>
+  </head>
+  <body>
+    <noscript>You need to enable JavaScript to run this app.</noscript>
+    <div id="root"></div>
+    <!--
+      This HTML file is a template.
+      If you open it directly in the browser, you will see an empty page.
+
+      You can add webfonts, meta tags, or analytics to this file.
+      The build step will place the bundled scripts into the <body> tag.
+
+      To begin the development, run `npm start` or `yarn start`.
+      To create a production bundle, use `npm run build` or `yarn build`.
+    -->
+  </body>
+</html>

BIN
examples/react/public/logo192.png


BIN
examples/react/public/logo512.png


+ 25 - 0
examples/react/public/manifest.json

@@ -0,0 +1,25 @@
+{
+  "short_name": "React App",
+  "name": "Create React App Sample",
+  "icons": [
+    {
+      "src": "favicon.ico",
+      "sizes": "64x64 32x32 24x24 16x16",
+      "type": "image/x-icon"
+    },
+    {
+      "src": "logo192.png",
+      "type": "image/png",
+      "sizes": "192x192"
+    },
+    {
+      "src": "logo512.png",
+      "type": "image/png",
+      "sizes": "512x512"
+    }
+  ],
+  "start_url": ".",
+  "display": "standalone",
+  "theme_color": "#000000",
+  "background_color": "#ffffff"
+}

+ 2 - 0
examples/react/public/robots.txt

@@ -0,0 +1,2 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *

+ 38 - 0
examples/react/src/App.css

@@ -0,0 +1,38 @@
+.App {
+  text-align: center;
+}
+
+.App-logo {
+  height: 40vmin;
+  pointer-events: none;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+  .App-logo {
+    animation: App-logo-spin infinite 20s linear;
+  }
+}
+
+.App-header {
+  background-color: #282c34;
+  min-height: 100vh;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  font-size: calc(10px + 2vmin);
+  color: white;
+}
+
+.App-link {
+  color: #61dafb;
+}
+
+@keyframes App-logo-spin {
+  from {
+    transform: rotate(0deg);
+  }
+  to {
+    transform: rotate(360deg);
+  }
+}

+ 13 - 0
examples/react/src/App.js

@@ -0,0 +1,13 @@
+import React from 'react';
+import './App.css';
+import Room from './pages/room';
+
+function App() {
+  return (
+    <div className="App">
+      <Room/>
+    </div>
+  );
+}
+
+export default App;

+ 9 - 0
examples/react/src/App.test.js

@@ -0,0 +1,9 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import App from './App';
+
+test('renders learn react link', () => {
+  const { getByText } = render(<App />);
+  const linkElement = getByText(/learn react/i);
+  expect(linkElement).toBeInTheDocument();
+});

+ 13 - 0
examples/react/src/components/MediaPlayer/index.css

@@ -0,0 +1,13 @@
+.media-player {
+  display: inline-block;
+  margin: 2px;
+  width: 300px;
+  text-align: left;
+  white-space: nowrap;
+  cursor: pointer;
+}
+
+.media-player video {
+  width: 100%;
+  height: 100%;
+}

+ 161 - 0
examples/react/src/components/MediaPlayer/index.jsx

@@ -0,0 +1,161 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import classnames from 'unique-classnames';
+import './index.css';
+
+export default class MediaPlayer extends Component {
+  static propTypes = {
+    className: PropTypes.string,
+    style: PropTypes.object,
+    stream: PropTypes.object,
+    client: PropTypes.object,
+    onClick: PropTypes.func,
+  };
+  static defaultProps = {
+    className: '',
+    style: {},
+    stream: {},
+    client: null,
+    onClick: () => { },
+  };
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      volume: 0,
+      stats: {
+        audioLost: 0,
+        biggestAudioLost: 0,
+        videoLost: 0,
+        biggestVideoLost: 0,
+        rtt: 0,
+        biggestRTT: 0
+      },
+    }
+    this.volumeTimer = 0;
+    this.stateTimer = 0;
+    this.videoElem = React.createRef();
+  }
+
+  componentDidMount() {
+    this.isComponentMounted = true;
+    this.play(this.props.stream.mediaStream);
+    this.startGetVolume();
+    this.startGetState();
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (nextProps.stream.mediaStream !== this.props.stream.mediaStream) {
+      this.play(nextProps.stream.mediaStream);
+    }
+  }
+
+  componentWillUnmount() {
+    this.stopGetVolume();
+    this.stopGetState();
+    this.stop();
+    this.isComponentMounted = false;
+  }
+
+  play(mediaStream) {
+    this.videoElem.current.srcObject = mediaStream;
+  }
+  stop() {
+    this.videoElem.current.srcObject = null;
+  }
+
+  startGetVolume() {
+    const { client, stream } = this.props;
+    if (!client || !stream || !stream.audio) {
+      return;
+    }
+    if (this.volumeTimer) {
+      clearInterval(this.volumeTimer);
+    }
+    this.volumeTimer = setInterval(() => {
+      const vol = client.getAudioVolume(stream.sid);
+      this.setState({ volume: vol })
+    }, 1000);
+  }
+  stopGetVolume() {
+    clearInterval(this.volumeTimer);
+  }
+
+  startGetState() {
+    const { client, stream } = this.props;
+    if (!client || !stream || !stream.video) {
+      return;
+    }
+    if (this.stateTimer) {
+      clearInterval(this.stateTimer);
+    }
+    this.stateTimer = setInterval(() => {
+      client.getAudioStats(stream.sid, (_stats) => {
+        if (!this.isComponentMounted) return;
+        const { stats } = this.state;
+        stats.audioLost = _stats.lostpre;
+        if (stats.biggestAudioLost < _stats.lostpre) {
+          stats.biggestAudioLost = _stats.lostpre;
+        }
+        this.setState({ stats });
+      }, (e) => {
+        console.error('get video stats ', stream.sid);
+      });
+      client.getVideoStats(stream.sid, (_stats) => {
+        if (!this.isComponentMounted) return;
+        const { stats } = this.state;
+        stats.videoLost = _stats.lostpre;
+        if (stats.biggestVideoLost < _stats.lostpre) {
+          stats.biggestVideoLost = _stats.lostpre;
+        }
+        this.setState({ stats });
+      }, (e) => {
+        console.error('get video stats ', stream.sid);
+      });
+      client.getNetworkStats(stream.sid, (_stats) => {
+        if (!this.isComponentMounted) return;
+        const { stats } = this.state;
+        stats.rtt = _stats.rtt;
+        if (stats.biggestRTT < _stats.rtt) {
+          stats.biggestRTT = _stats.rtt;
+        }
+        this.setState({ stats });
+      }, (e) => {
+        console.error('get network stats ', stream.sid);
+      });
+    }, 1000);
+  }
+  stopGetState() {
+    clearInterval(this.stateTimer);
+  }
+
+  handleClick = () => {
+    const { stream, onClick } = this.props;
+    onClick && onClick(stream);
+  }
+
+  render() {
+    const { stream, className, style } = this.props;
+    const { volume, stats } = this.state;
+
+    const classes = classnames('media-player', className);
+
+    return (
+      <div className={classes} style={style} onClick={this.handleClick}>
+        <div style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>用户ID: {stream.uid}</div>
+        <div style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>流ID: {stream.sid}</div>
+        <div style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>音量: {volume} % &nbsp;&nbsp;&nbsp;&nbsp;音频丢包率: {stats.audioLost} %</div>
+        <div style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>视频丢包率: {stats.videoLost} % &nbsp;&nbsp;&nbsp;&nbsp;网络延时: {stats.rtt} ms</div>
+        <div style={{ width: '100%' }}>
+          <video
+            ref={this.videoElem}
+            webkit-playsinline="true"
+            autoPlay
+            playsInline>
+          </video>
+        </div>
+      </div>
+    )
+  }
+}

+ 15 - 0
examples/react/src/config/README.md

@@ -0,0 +1,15 @@
+本目录中需要创建 index.js 文件,并配置 AppId 和 AppKey,示例代码:
+
+```
+const config = {
+  AppId: 'urtc-xxxxxxxx',
+  AppKey: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'
+}
+
+export default config;
+```
+
+> 注:
+> 
+> 1. AppId 和 AppKey 可从 URTC 产品中获取
+> 2. AppKey 不可暴露于公网,建议生产环境时,由后端进行保存并由前端调 API 获取

+ 13 - 0
examples/react/src/index.css

@@ -0,0 +1,13 @@
+body {
+  margin: 0;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+    'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
+    sans-serif;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+}
+
+code {
+  font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
+    monospace;
+}

+ 7 - 0
examples/react/src/index.js

@@ -0,0 +1,7 @@
+import React from 'react';
+import ReactDOM from 'react-dom';
+import 'normalize.css';
+import './index.css';
+import App from './App';
+
+ReactDOM.render(<App />, document.getElementById('root'));

File diff suppressed because it is too large
+ 7 - 0
examples/react/src/logo.svg


+ 25 - 0
examples/react/src/pages/room/index.css

@@ -0,0 +1,25 @@
+.room {
+  max-width: 640px;
+  margin: 0 auto;
+}
+
+.room input {
+  padding: 4px 0;
+}
+
+.room button {
+  padding: 8px 0;
+  display: inline-block;
+  width: 100%;
+  border-radius: 6px;
+  cursor: pointer;
+  text-align: center;;
+}
+
+.room input:visited,
+.room button:focus,
+.room button:visited,
+.room button:hover,
+.room button:active {
+  outline: none;
+}

+ 239 - 0
examples/react/src/pages/room/index.jsx

@@ -0,0 +1,239 @@
+import React, { Component } from 'react';
+import sdk, { Client } from 'urtc-sdk';
+
+import config from '../../config';
+import MediaPlayer from '../../components/MediaPlayer';
+import './index.css';
+
+const { AppId, AppKey } = config;
+
+// 此处使用固定的房间号的随机的用户ID,请自行替换
+const RoomId = "ssss02";
+const UserId = Math.floor(Math.random() * 1000000).toString();
+
+console.log('UCloudRTC sdk version: ', sdk.version);
+
+export default class Room extends Component {
+  constructor() {
+    super();
+    this.state = {
+      roomId: RoomId,
+      userId: UserId,
+      isJoinedRoom: false,
+      selectedStream: null,
+      localStreams: [],
+      remoteStreams: [],
+    }
+  }
+
+  componentDidMount() {
+    if (!AppId || !AppKey) {
+      alert('请先设置 AppId 和 AppKey');
+      return;
+    }
+    if (!RoomId) {
+      alert('请先设置 RoomId');
+      return;
+    }
+    if (!UserId) {
+      alert('请先设置 UserId');
+      return;
+    }
+    const token = sdk.generateToken(AppId, AppKey, RoomId, UserId);
+    this.client = new Client(AppId, token);
+    this.client.on('stream-published', (localStream) => {
+      console.info('stream-published: ', localStream);
+      const { localStreams } = this.state;
+      localStreams.push(localStream);
+      this.setState({ localStreams });
+    });
+    this.client.on('stream-added', (remoteStream) => {
+      console.info('stream-added: ', remoteStream);
+      const { remoteStreams } = this.state;
+      remoteStreams.push(remoteStream);
+      // 自动订阅
+      this.client.subscribe(remoteStream.sid, (err) => {
+        console.error('自动订阅失败:', err);
+      });
+      this.setState({ remoteStreams });
+    });
+    this.client.on('stream-subscribed', (remoteStream) => {
+      console.info('stream-subscribed: ', remoteStream);
+      const { remoteStreams } = this.state;
+      const idx = remoteStreams.findIndex(item => item.sid === remoteStream.sid);
+      if (idx >= 0 ) {
+        remoteStreams.splice(idx, 1, remoteStream);
+      }
+      this.setState({ remoteStreams });
+    });
+    this.client.on('stream-removed', (remoteStream) => {
+      console.info('stream-removed: ', remoteStream);
+      const { remoteStreams } = this.state;
+      const idx = remoteStreams.findIndex(item => item.sid === remoteStream.sid);
+      if (idx >= 0 ) {
+        remoteStreams.splice(idx, 1);
+      }
+      this.setState({ remoteStreams });
+    });
+
+    window.addEventListener('beforeunload', this.handleLeaveRoom);
+  }
+
+  componentWillUnmount() {
+    console.info('component will unmout');
+    window.removeEventListener('beforeunload', this.handleLeaveRoom);
+    this.handleLeaveRoom();
+  }
+
+  handleJoinRoom = () => {
+    const { roomId, userId, isJoinedRoom } = this.state;
+    if (isJoinedRoom) {
+      alert('已经加入了房间');
+      return;
+    }
+    if (!roomId) {
+      alert('请先填写房间号');
+      return;
+    }
+    this.client.joinRoom(roomId, userId, () => {
+      console.info('加入房间成功');
+      this.setState({ isJoinedRoom: true });
+    }, (err) => {
+      console.error('加入房间失败: ', err);
+    });
+  }
+
+  handlePublish = () => {
+    this.client.publish(err => {
+      console.error('发布失败:', err);
+    });
+  }
+  handlePublishScreen = () => {
+    this.client.publish({audio: true, video: false, screen: true}, err => {
+      console.error('发布失败:', err);
+    });
+  }
+
+  handleUnpublish = () => {
+    const { selectedStream } = this.state;
+    if (!selectedStream) {
+      alert('未选择需要取消发布的本地流');
+      return;
+    }
+    this.client.unpublish(selectedStream.sid, (stream) => {
+      console.info('取消发布本地流成功:', stream);
+      const { localStreams } = this.state;
+      const idx = localStreams.findIndex(item => item.sid === stream.sid);
+      if (idx >=0) {
+        localStreams.splice(idx, 1);
+      }
+      this.setState({
+        localStreams,
+        selectedStream: null
+      });
+    }, (err) => {
+      console.error('取消发布本地流失败:', err);
+    })
+  }
+  
+  handleSubscribe = () => {
+    const { selectedStream } = this.state;
+    if (!selectedStream) {
+      alert('未选择需要订阅的远端流');
+      return;
+    }
+    this.client.subscribe(selectedStream.sid, (err) => {
+      console.error('订阅失败:', err);
+    });
+  }
+
+  handleUnsubscribe = () => {
+    const { selectedStream } = this.state;
+    if (!selectedStream) {
+      alert('未选择需要取消订阅的远端流');
+      return;
+    }
+    this.client.unsubscribe(selectedStream.sid, (stream) => {
+      console.info('取消订阅成功:', stream);
+      const { remoteStreams } = this.state;
+      const idx = remoteStreams.findIndex(item => item.sid === stream.sid);
+      if (idx >=0) {
+        remoteStreams.splice(idx, 1, stream);
+      }
+      this.setState({
+        remoteStreams,
+      });
+    }, (err) => {
+      console.error('订阅失败:', err);
+    });
+  }
+
+  handleLeaveRoom = () => {
+    const { isJoinedRoom } = this.state;
+    if (!isJoinedRoom) {
+      return;
+    }
+    this.client.leaveRoom(() => {
+      console.info('离开房间成功');
+      this.setState({
+        selectedStream: null,
+        localStreams: [],
+        remoteStreams: [],
+        isJoinedRoom: false,
+      });
+    }, (err) => {
+      console.error('离开房间失败:', err);
+    });
+  }
+
+  handleSelectStream = (stream) => {
+    console.log('select stream: ', stream);
+    this.setState({ selectedStream: stream });
+  }
+  renderLocalStream() {
+    const { localStreams } = this.state;
+    return localStreams.map(stream => {
+      return stream.mediaStream ?
+        <MediaPlayer className="local-stream" key={stream.sid} client={this.client} stream={stream} onClick={this.handleSelectStream}/> :
+        null;
+    });
+  }
+  renderRemoteStream() {
+    const { remoteStreams } = this.state;
+    return remoteStreams.map(stream => {
+      return stream.mediaStream ?
+        <MediaPlayer className="remote-stream" key={stream.sid} client={this.client} stream={stream} onClick={this.handleChooseStream} /> :
+        <div className="remote-stream" key={stream.sid} onClick={() => { this.handleSelectStream(stream) }}>
+          <div style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>用户ID: {stream.uid}</div>
+          <div style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}>流ID: {stream.sid}</div>
+          <div style={{ width: '100%' }}> unsubscribe </div>
+        </div>;
+    });
+  }
+
+  render() {
+    const { selectedStream, isJoinedRoom } = this.state;
+    return (
+      <div className="room">
+        <label>房间号:{RoomId}({isJoinedRoom ? '已加入' : '未加入'})</label>
+        <p>当前选中的流:{selectedStream ? selectedStream.sid : '未选择'}</p>
+        <h3>本地(发布)流</h3>
+        {
+          this.renderLocalStream()
+        }
+        <h3>远端(订阅)流</h3>
+        {
+          this.renderRemoteStream()
+        }
+        <h3>操作</h3>
+        <button onClick={this.handleJoinRoom}>加入房间</button>
+        <button onClick={this.handlePublish}>发布</button>
+        <button onClick={this.handlePublishScreen}>屏幕共享</button>
+        <button onClick={this.handleUnpublish}>取消发布/屏幕共享</button>
+        <button onClick={this.handleSubscribe}>订阅</button>
+        <button onClick={this.handleUnsubscribe}>取消订阅</button>
+        <button onClick={this.handleLeaveRoom}>离开房间</button>
+      </div>
+    )
+  }
+}

+ 5 - 0
examples/react/src/setupTests.js

@@ -0,0 +1,5 @@
+// jest-dom adds custom jest matchers for asserting on DOM nodes.
+// allows you to do things like:
+// expect(element).toHaveTextContent(/react/i)
+// learn more: https://github.com/testing-library/jest-dom
+import '@testing-library/jest-dom/extend-expect';