diff --git a/react-native-expo-app/README.md b/react-native-expo-app/README.md index 55eeca4..532ad0b 100644 --- a/react-native-expo-app/README.md +++ b/react-native-expo-app/README.md @@ -1,3 +1,47 @@ [React Native WebRTC Examples](https://github.com/react-native-webrtc/examples) # React Native Expo App + +This is a demo app of using WebRTC with React Native. It's built with Expo and uses a [custom development build](https://docs.expo.dev/develop/development-builds/introduction/). + + + +| Join | Outgoing call | Incoming call | On call | +| -------------------------------------------------------------- | --------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| Join screen | Outgoing screen | | On call screen | + +## How to build + +The easy way to build this app is to use Expo's build service (EAS). Although you can [build it locally](https://docs.expo.dev/build-reference/local-builds/) if you prefer. + +Before you can build the app you need a few things: + +1. An Expo account +2. Create a new project on EAS +3. Update `extra.eas.projectId` in `client/app.json` with your project ID + +After you linked your project to EAS, you can run `yarn build:dev` on the `client` directory to start a dev build. + +This will generate a custom Expo client that contains `react-native-webrtc` and other packages that are not available on the Expo Go app. + +Take a look at `client/package.json` to see other build scripts. + +## How to run + +1. Clone this repo +2. Run `yarn install` on both `client` and `server` directories (in separate terminals) +3. Update `EXPO_PUBLIC_SIGNALING_SERVER_URL` in `client/.env` with your signaling server URL (this can be your computer's IP address) +4. Run `yarn start` on both `client` and `server` directories +5. Install the custom dev client on your mobile devices +6. Make sure the server and both devices are on the same network +7. Open the app on two devices and start a video call + +## How it works + +For the client, there are three files that you want to take a look at (the rest can be ignored): + +1. `client/App.tsx` - This is the main entry point of the app. It contains the UI and links everything together. +2. `client/src/signaling.ts` - This contains a class that lets you create [signaling channels](https://webrtc.org/getting-started/peer-connections#signaling). +3. `client/src/useWebRTC.ts` - This is the most interesting file. It contains all the logic for creating and managing WebRTC connections. + +The server is only used for signling. It's a simple [Socket.IO](https://socket.io/) server that relays messages between clients. diff --git a/react-native-expo-app/client/.env b/react-native-expo-app/client/.env new file mode 100644 index 0000000..5cdf7ee --- /dev/null +++ b/react-native-expo-app/client/.env @@ -0,0 +1 @@ +EXPO_PUBLIC_SIGNALING_SERVER_URL=http://192.168.1.200:3000 \ No newline at end of file diff --git a/react-native-expo-app/client/.eslintrc.json b/react-native-expo-app/client/.eslintrc.json new file mode 100644 index 0000000..28e162c --- /dev/null +++ b/react-native-expo-app/client/.eslintrc.json @@ -0,0 +1,68 @@ +{ + "env": { + "es2021": true, + "react-native/react-native": true + }, + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react/jsx-runtime", + // "plugin:import/recommended", + "plugin:import/typescript" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaFeatures": { + "jsx": true + }, + "ecmaVersion": 12, + "sourceType": "module" + }, + "plugins": [ + "react", + "@typescript-eslint", + "react-hooks", + "react-native", + "import" + ], + "rules": { + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/no-extra-semi": "off", + "@typescript-eslint/no-explicit-any": "off", + "react-hooks/rules-of-hooks": "error", + "react-hooks/exhaustive-deps": "warn", + "react-native/no-unused-styles": "off", + "react-native/no-single-element-style-arrays": "warn", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-var-requires": "off", + "react/prop-types": "off", + "no-undef": "off", + "no-useless-escape": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "react-native/no-raw-text": "warn", + "react/display-name": "off", + "react/no-unescaped-entities": "off", + "no-case-declarations": "off", + "import/no-unused-modules": [ + 1, + { + "unusedExports": true, + "ignoreExports": [ + "./app", + "./redux", + "app.config.js" + ] + } + ] + }, + "settings": { + "react": { + "version": "detect" + }, + "import/resolver": { + "typescript": true, + "node": true + } + } +} \ No newline at end of file diff --git a/react-native-expo-app/client/.gitignore b/react-native-expo-app/client/.gitignore new file mode 100644 index 0000000..05647d5 --- /dev/null +++ b/react-native-expo-app/client/.gitignore @@ -0,0 +1,35 @@ +# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files + +# dependencies +node_modules/ + +# Expo +.expo/ +dist/ +web-build/ + +# Native +*.orig.* +*.jks +*.p8 +*.p12 +*.key +*.mobileprovision + +# Metro +.metro-health-check* + +# debug +npm-debug.* +yarn-debug.* +yarn-error.* + +# macOS +.DS_Store +*.pem + +# local env files +.env*.local + +# typescript +*.tsbuildinfo diff --git a/react-native-expo-app/client/.prettierrc b/react-native-expo-app/client/.prettierrc new file mode 100644 index 0000000..302bb66 --- /dev/null +++ b/react-native-expo-app/client/.prettierrc @@ -0,0 +1,7 @@ +{ + "printWidth": 100, + "tabWidth": 2, + "singleQuote": false, + "trailingComma": "es5", + "semi": false +} diff --git a/react-native-expo-app/client/App.tsx b/react-native-expo-app/client/App.tsx new file mode 100644 index 0000000..f631607 --- /dev/null +++ b/react-native-expo-app/client/App.tsx @@ -0,0 +1,52 @@ +import { useRef, useState } from "react" +import IncomingView from "src/IncomingView" +import JoinView from "src/JoinView" +import MeetingView from "src/MeetingView" +import OutgoingView from "src/OutgoingView" +import { useWebRTC } from "src/useWebRTC" + +const App = () => { + const localClientId = useRef(Math.floor(100000 + Math.random() * 900000).toString()) + const [remoteClientId, setRemoteClientId] = useState("") + const { + callState, + localStream, + remoteStream, + makeCall, + acceptCall, + endCall, + isMicEnabled, + isCameraEnabled, + isFrontCamera, + toggleMute, + toggleCamera, + toggleCameraMode, + } = useWebRTC(localClientId.current, setRemoteClientId) + + return callState === "idle" ? ( + { + setRemoteClientId(remoteClientIdArg) + makeCall(remoteClientIdArg) + }} + /> + ) : callState === "incoming" ? ( + + ) : callState === "outgoing" ? ( + null} /> + ) : callState === "connected" ? ( + + ) : null +} +export default App diff --git a/react-native-expo-app/client/app.json b/react-native-expo-app/client/app.json new file mode 100644 index 0000000..664c08c --- /dev/null +++ b/react-native-expo-app/client/app.json @@ -0,0 +1,32 @@ +{ + "expo": { + "name": "WebRTC Expo Example", + "slug": "webrtc-expo-example", + "version": "1.0.0", + "orientation": "portrait", + "icon": "./assets/icon.png", + "userInterfaceStyle": "light", + "splash": { + "image": "./assets/splash.png", + "resizeMode": "contain", + "backgroundColor": "#ffffff" + }, + "plugins": [ + "@config-plugins/react-native-webrtc" + ], + "extra": { + "eas": { + "projectId": "298b5afc-c9dd-42ff-9188-bac1105c6a0b" + } + }, + "experiments": { + "tsconfigPaths": true + }, + "ios": { + "bundleIdentifier": "com.example.webrtcexpoexample" + }, + "android": { + "package": "com.example.webrtcexpoexample" + } + } +} \ No newline at end of file diff --git a/react-native-expo-app/client/assets/icon.png b/react-native-expo-app/client/assets/icon.png new file mode 100644 index 0000000..bbb8f40 Binary files /dev/null and b/react-native-expo-app/client/assets/icon.png differ diff --git a/react-native-expo-app/client/assets/splash.png b/react-native-expo-app/client/assets/splash.png new file mode 100644 index 0000000..0e89705 Binary files /dev/null and b/react-native-expo-app/client/assets/splash.png differ diff --git a/react-native-expo-app/client/assets/svgs/CallAnswer.js b/react-native-expo-app/client/assets/svgs/CallAnswer.js new file mode 100644 index 0000000..189fba1 --- /dev/null +++ b/react-native-expo-app/client/assets/svgs/CallAnswer.js @@ -0,0 +1,16 @@ +import * as React from 'react'; +import Svg, {Path} from 'react-native-svg'; + +const SvgComponent = props => ( + + + + +); + +export default SvgComponent; diff --git a/react-native-expo-app/client/assets/svgs/CallEnd.js b/react-native-expo-app/client/assets/svgs/CallEnd.js new file mode 100644 index 0000000..6971f9e --- /dev/null +++ b/react-native-expo-app/client/assets/svgs/CallEnd.js @@ -0,0 +1,15 @@ +import * as React from "react" +import Svg, { Path } from "react-native-svg" + +function SvgComponent(props) { + return ( + + + + ) +} + +export default SvgComponent diff --git a/react-native-expo-app/client/assets/svgs/CameraSwitch.js b/react-native-expo-app/client/assets/svgs/CameraSwitch.js new file mode 100644 index 0000000..6e8b5a3 --- /dev/null +++ b/react-native-expo-app/client/assets/svgs/CameraSwitch.js @@ -0,0 +1,22 @@ +import * as React from "react"; +import Svg, { Defs, Path } from "react-native-svg"; + +function CameraSwitch(props) { + return ( + + + + + + ); +} + +export default CameraSwitch; diff --git a/react-native-expo-app/client/assets/svgs/Leave.js b/react-native-expo-app/client/assets/svgs/Leave.js new file mode 100644 index 0000000..8e5c4df --- /dev/null +++ b/react-native-expo-app/client/assets/svgs/Leave.js @@ -0,0 +1,27 @@ +import * as React from "react"; +import Svg, { G, Path, Defs, ClipPath } from "react-native-svg"; + +function Leave(props) { + return ( + + + + + + + + + + + + ); +} + +export default Leave; diff --git a/react-native-expo-app/client/assets/svgs/MicOff.js b/react-native-expo-app/client/assets/svgs/MicOff.js new file mode 100644 index 0000000..e02cf3b --- /dev/null +++ b/react-native-expo-app/client/assets/svgs/MicOff.js @@ -0,0 +1,16 @@ +import * as React from 'react'; +import Svg, {Path} from 'react-native-svg'; + +function MicOff(props) { + return ( + + + + + ); +} + +export default MicOff; diff --git a/react-native-expo-app/client/assets/svgs/MicOn.js b/react-native-expo-app/client/assets/svgs/MicOn.js new file mode 100644 index 0000000..119c396 --- /dev/null +++ b/react-native-expo-app/client/assets/svgs/MicOn.js @@ -0,0 +1,32 @@ +import * as React from 'react'; +import Svg, {Defs, ClipPath, Path, G} from 'react-native-svg'; + +function MicOn(props) { + return ( + + + + + + + + + + + + ); +} + +export default MicOn; diff --git a/react-native-expo-app/client/assets/svgs/VideoOff.js b/react-native-expo-app/client/assets/svgs/VideoOff.js new file mode 100644 index 0000000..100ab8f --- /dev/null +++ b/react-native-expo-app/client/assets/svgs/VideoOff.js @@ -0,0 +1,24 @@ +import * as React from "react"; +import Svg, { Defs, ClipPath, Path, G } from "react-native-svg"; + +function VideoOff(props) { + return ( + + + + + + + + + + + ); +} + +export default VideoOff; diff --git a/react-native-expo-app/client/assets/svgs/VideoOn.js b/react-native-expo-app/client/assets/svgs/VideoOn.js new file mode 100644 index 0000000..d31dd7b --- /dev/null +++ b/react-native-expo-app/client/assets/svgs/VideoOn.js @@ -0,0 +1,15 @@ +import * as React from "react"; +import Svg, { Path } from "react-native-svg"; + +function VideoOn(props) { + return ( + + + + ); +} + +export default VideoOn; diff --git a/react-native-expo-app/client/babel.config.js b/react-native-expo-app/client/babel.config.js new file mode 100644 index 0000000..2900afe --- /dev/null +++ b/react-native-expo-app/client/babel.config.js @@ -0,0 +1,6 @@ +module.exports = function(api) { + api.cache(true); + return { + presets: ['babel-preset-expo'], + }; +}; diff --git a/react-native-expo-app/client/eas.json b/react-native-expo-app/client/eas.json new file mode 100644 index 0000000..d629d8d --- /dev/null +++ b/react-native-expo-app/client/eas.json @@ -0,0 +1,23 @@ +{ + "cli": { + "version": ">= 5.0.0" + }, + "build": { + "development": { + "developmentClient": true, + "distribution": "internal", + "android": { + "buildType": "apk" + } + }, + "preview": { + "distribution": "internal", + "android": { + "buildType": "apk" + } + }, + "production": { + "distribution": "store" + } + } +} \ No newline at end of file diff --git a/react-native-expo-app/client/package.json b/react-native-expo-app/client/package.json new file mode 100644 index 0000000..2c9cace --- /dev/null +++ b/react-native-expo-app/client/package.json @@ -0,0 +1,37 @@ +{ + "name": "react-native-webrtc-expo-example-client", + "version": "1.0.0", + "main": "node_modules/expo/AppEntry.js", + "author": { + "name": "Mohammed Handa", + "email": "mohamadhenda@gmail.com", + "url": "https://mohammedhanda.com" + }, + "scripts": { + "start": "expo start", + "android": "expo start --android", + "ios": "expo start --ios", + "web": "expo start --web", + "build:dev": "eas build --profile=development", + "build:preview": "eas build --profile=preview" + }, + "dependencies": { + "@config-plugins/react-native-webrtc": "^8.0.0", + "event-target-shim": "^6.0.2", + "events": "^3.3.0", + "expo": "~50.0.7", + "expo-dev-client": "~3.3.8", + "expo-status-bar": "~1.11.1", + "react": "18.2.0", + "react-native": "0.73.4", + "react-native-svg": "14.1.0", + "react-native-webrtc": "^118.0.1", + "socket.io-client": "^4.7.4" + }, + "devDependencies": { + "@babel/core": "^7.20.0", + "@types/react": "^18.2.56", + "typescript": "^5.3.3" + }, + "private": true +} diff --git a/react-native-expo-app/client/src/IconContainer.tsx b/react-native-expo-app/client/src/IconContainer.tsx new file mode 100644 index 0000000..7038039 --- /dev/null +++ b/react-native-expo-app/client/src/IconContainer.tsx @@ -0,0 +1,31 @@ +import React, { FC } from "react" +import { TouchableOpacity } from "react-native" + +type Props = { + backgroundColor?: string + onPress: () => void + Icon: FC + style?: any +} + +const IconContainer: FC = ({ backgroundColor, onPress, Icon, style }) => { + return ( + + + + ) +} +export default IconContainer diff --git a/react-native-expo-app/client/src/IncomingView.tsx b/react-native-expo-app/client/src/IncomingView.tsx new file mode 100644 index 0000000..99d38c6 --- /dev/null +++ b/react-native-expo-app/client/src/IncomingView.tsx @@ -0,0 +1,63 @@ +import CallAnswer from "assets/svgs/CallAnswer" +import { FC } from "react" +import { StyleProp, Text, TouchableOpacity, View, ViewStyle } from "react-native" + +type Props = { + style?: StyleProp + remoteClientId: string + onAnswer: () => void +} + +const IncomingView: FC = ({ remoteClientId, onAnswer }) => { + return ( + + + + {remoteClientId} is calling.. + + + + { + onAnswer() + }} + style={{ + backgroundColor: "green", + borderRadius: 30, + height: 60, + aspectRatio: 1, + justifyContent: "center", + alignItems: "center", + }} + > + + + + + ) +} +export default IncomingView diff --git a/react-native-expo-app/client/src/JoinView.tsx b/react-native-expo-app/client/src/JoinView.tsx new file mode 100644 index 0000000..d6524c4 --- /dev/null +++ b/react-native-expo-app/client/src/JoinView.tsx @@ -0,0 +1,112 @@ +import TextInputContainer from "./TextInputContainer" +import { FC, useState } from "react" +import { Keyboard, Text, TouchableOpacity, TouchableWithoutFeedback, View } from "react-native" + +type Props = { + localClientId: string + onCall: (remoteClientId: string) => void +} + +const JoinView: FC = ({ localClientId, onCall }) => { + const [otherUserId, setOtherUserId] = useState("") + + return ( + + + <> + + + Your Caller ID + + + + {localClientId} + + + + + + + Enter the other user's ID + + + { + onCall(otherUserId) + }} + style={{ + height: 50, + backgroundColor: "#5568FE", + justifyContent: "center", + alignItems: "center", + borderRadius: 12, + marginTop: 16, + }} + > + + Call Now + + + + + + + ) +} +export default JoinView diff --git a/react-native-expo-app/client/src/MeetingView.tsx b/react-native-expo-app/client/src/MeetingView.tsx new file mode 100644 index 0000000..b0e6680 --- /dev/null +++ b/react-native-expo-app/client/src/MeetingView.tsx @@ -0,0 +1,148 @@ +import CallEnd from "assets/svgs/CallEnd" +import CameraSwitch from "assets/svgs/CameraSwitch" +import MicOff from "assets/svgs/MicOff" +import MicOn from "assets/svgs/MicOn" +import VideoOff from "assets/svgs/VideoOff" +import VideoOn from "assets/svgs/VideoOn" +import IconContainer from "./IconContainer" +import { FC } from "react" +import { View } from "react-native" +import { MediaStream, RTCView } from "react-native-webrtc" + +type Props = { + localStream?: MediaStream + remoteStream?: MediaStream + onLeave?: () => void + onToggleMic?: () => void + onToggleCamera?: () => void + onSwitchCamera?: () => void + isMuted?: boolean + isCameraOn?: boolean + isFrontCamera?: boolean +} + +const MeetingView: FC = ({ + localStream, + remoteStream, + onLeave, + onToggleCamera, + onSwitchCamera, + onToggleMic, + isCameraOn, + isMuted, + isFrontCamera, +}) => { + + return ( + + + {remoteStream ? ( + + ) : ( + + )} + + + {localStream ? ( + + ) : ( + + )} + + + { + onLeave?.() + }} + Icon={() => { + return + }} + /> + { + onToggleMic?.() + }} + Icon={() => { + return isMuted ? ( + + ) : ( + + ) + }} + /> + { + onToggleCamera?.() + }} + Icon={() => { + return isCameraOn ? ( + + ) : ( + + ) + }} + /> + { + onSwitchCamera?.() + }} + Icon={() => { + return + }} + /> + + + ) +} +export default MeetingView diff --git a/react-native-expo-app/client/src/OutgoingView.tsx b/react-native-expo-app/client/src/OutgoingView.tsx new file mode 100644 index 0000000..b16242b --- /dev/null +++ b/react-native-expo-app/client/src/OutgoingView.tsx @@ -0,0 +1,72 @@ +import CallEnd from "assets/svgs/CallEnd" +import { FC } from "react" +import { Text, TouchableOpacity, View } from "react-native" + +type Props = { + remoteClientId: string + onCancel: () => void +} + +const OutgoingView: FC = ({ remoteClientId, onCancel }) => { + return ( + + + + Calling to... + + + + {remoteClientId} + + + + { + onCancel() + }} + style={{ + backgroundColor: "#FF5D5D", + borderRadius: 30, + height: 60, + aspectRatio: 1, + justifyContent: "center", + alignItems: "center", + }} + > + + + + + ) +} +export default OutgoingView diff --git a/react-native-expo-app/client/src/TextInputContainer.tsx b/react-native-expo-app/client/src/TextInputContainer.tsx new file mode 100644 index 0000000..2011fde --- /dev/null +++ b/react-native-expo-app/client/src/TextInputContainer.tsx @@ -0,0 +1,45 @@ +import React, { ComponentProps, FC } from "react" +import { View, TextInput } from "react-native" + +type Props = { + placeholder: string + value: string + setValue: (text: string) => void + keyboardType: ComponentProps["keyboardType"] +} + +const TextInputContainer: FC = ({ placeholder, value, setValue, keyboardType }) => { + return ( + + + + ) +} + +export default TextInputContainer diff --git a/react-native-expo-app/client/src/signaling.ts b/react-native-expo-app/client/src/signaling.ts new file mode 100644 index 0000000..c8f867a --- /dev/null +++ b/react-native-expo-app/client/src/signaling.ts @@ -0,0 +1,96 @@ +import { EventEmitter } from "events" +import { io, Socket } from "socket.io-client" + +export type SignalingMessage = { type: string; payload: any } + +/** + * Serves as a bridge between two clients to pass messages + * + * **Note**: Messages are first sent to the server and then forwarded to the desired client + */ +export class SignalingChannel extends EventEmitter { + private socket: Socket + private _remoteClientId: string | null = null + get remoteClientId() { + return this._remoteClientId + } + + /** + * + * @param localClientId id of the current client + */ + constructor(private localClientId: string) { + super() + if (!process.env.EXPO_PUBLIC_SIGNALING_SERVER_URL) { + throw new Error("EXPO_PUBLIC_SIGNALING_SERVER_URL is not defined") + } + this.socket = io(process.env.EXPO_PUBLIC_SIGNALING_SERVER_URL, { + transports: ["websocket"], + query: { + localClientId, + }, + }) + this.socket.on("message", (message: SignalingMessage) => { + this.emit("message", message) + }) + this.socket.on("connectTo", (remoteClientId: string) => { + this._remoteClientId = remoteClientId + console.log(`${this.localClientId} accepted connectTo ${remoteClientId}`) + }) + this.socket.on("disconnectFrom", (remoteClientId: string) => { + if (this._remoteClientId !== remoteClientId) { + throw new Error("Remote client id did not match while disconnecting") + } + this._remoteClientId = null + this.emit("disconnected") + console.log(`${this.localClientId} accepted disconnect`) + }) + } + + /** + * Connect to the remote client by sending the current client id to the remote client + * This will let the other client know which client it's connecting to + * @param remoteClientId id of the remote client + */ + connect(remoteClientId: string) { + this._remoteClientId = remoteClientId + console.log(`${this.localClientId} want to connectTo ${remoteClientId}`) + this.socket.emit("connectTo", remoteClientId) + } + + /** + * Disconnect from the remote client + */ + disconnect() { + this.socket.emit("disconnectFrom", this._remoteClientId) + this._remoteClientId = null + console.log(`${this.localClientId} want to disconnect`) + } + + /** + * Send message to the remote client + * @param message message to send + */ + send(message: SignalingMessage) { + if (!this._remoteClientId) { + throw new Error(`Attempted to send message without remote client id | type: ${message.type}`) + } + this.socket.emit( + "message", + { + remoteClientId: this._remoteClientId, + message, + }, + (ack: any) => { + console.log(`ack`, ack) + } + ) + } + + /** + * Close the connection + */ + close() { + this.socket.close() + } +} diff --git a/react-native-expo-app/client/src/useWebRTC.ts b/react-native-expo-app/client/src/useWebRTC.ts new file mode 100644 index 0000000..dfdf409 --- /dev/null +++ b/react-native-expo-app/client/src/useWebRTC.ts @@ -0,0 +1,266 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { + MediaStream, + RTCIceCandidate, + RTCPeerConnection, + RTCSessionDescription, + mediaDevices, +} from "react-native-webrtc" +import RTCIceCandidateEvent from "react-native-webrtc/lib/typescript/RTCIceCandidateEvent" +import RTCTrackEvent from "react-native-webrtc/lib/typescript/RTCTrackEvent" +import { SignalingChannel, SignalingMessage } from "./signaling" + +export const useWebRTC = ( + localClientId: string, + onIncomingCall?: (remoteClientId: string) => void +) => { + //* state and config ---------------------------------------------------------------------------- + const [callState, setCallState] = useState<"idle" | "outgoing" | "incoming" | "connected">("idle") + const [isMicEnabled, setIsMicEnabled] = useState(true) + const [isCameraEnabled, setIsCameraEnabled] = useState(true) + const [isFrontCamera, setIsFrontCamera] = useState(true) + const [pendingOffer, setPendingOffer] = useState() + const remoteCandidates = useRef([]) + const signalingChannel = useMemo(() => new SignalingChannel(localClientId), [localClientId]) + const localStream = useRef() + const remoteStream = useRef(new MediaStream()) + const mediaConstraints = useMemo( + () => ({ + audio: true, + video: { + width: 1080, + height: 1920, + frameRate: 60, + }, + }), + [] + ) + const iceServers = useMemo( + () => [ + { urls: "stun:stun.l.google.com:19302" }, + { urls: "stun:stun1.l.google.com:19302" }, + { urls: "stun:stun2.l.google.com:19302" }, + ], + [] + ) + const [peerConnection, setPeerConnection] = useState( + new RTCPeerConnection({ + iceServers, + }) + ) + //* reinitialize -------------------------------------------------------------------------------- + /** + * Reset everything to the initial state + * + * Should be called after the call ends + */ + const reset = useCallback(() => { + peerConnection.close() + localStream.current?.getTracks().forEach((track) => { + track.stop() + }) + remoteStream.current.getTracks().forEach((track) => { + track.stop() + }) + localStream.current = new MediaStream() + remoteStream.current = new MediaStream() + setPeerConnection( + new RTCPeerConnection({ + iceServers, + }) + ) + setCallState("idle") + setPendingOffer(undefined) + remoteCandidates.current = [] + }, [iceServers, peerConnection]) + //* set up local stream ------------------------------------------------------------------------- + useEffect(() => { + ;(async () => { + const stream = await mediaDevices.getUserMedia(mediaConstraints) + stream?.getTracks().forEach((track) => { + peerConnection.addTrack(track, stream) + }) + localStream.current = stream + })() + }, [mediaConstraints, peerConnection]) + //* make call ----------------------------------------------------------------------------------- + const makeCall = useCallback( + async (otherUserIdArg: string) => { + // connect to the remote client through the signaling server + signalingChannel?.connect(otherUserIdArg) + // create offer and send it to the other client + const offer = await peerConnection.createOffer({ + offerToReceiveAudio: true, + offerToReceiveVideo: true, + }) + await peerConnection.setLocalDescription(offer) + signalingChannel?.send({ type: "offer", payload: offer }) + setCallState("outgoing") + }, + [peerConnection, signalingChannel] + ) + //* listen for incoming calls ------------------------------------------------------------------- + useEffect(() => { + const offerEventListener = async (message: SignalingMessage) => { + if (message.type === "offer") { + setPendingOffer(message.payload) + onIncomingCall?.(signalingChannel.remoteClientId ?? "") + setCallState("incoming") + } + } + signalingChannel?.on("message", offerEventListener) + return () => { + signalingChannel?.off("message", offerEventListener) + } + }, [onIncomingCall, signalingChannel]) + //* accept call --------------------------------------------------------------------------------- + const acceptCall = useCallback(async () => { + if (!pendingOffer) { + throw new Error("Attempted to accept call without pending offer") + } + // set remote description and send answer + await peerConnection.setRemoteDescription(new RTCSessionDescription(pendingOffer)) + const answer = await peerConnection.createAnswer() + await peerConnection.setLocalDescription(answer) + signalingChannel?.send({ type: "answer", payload: answer }) + // add remote candidates that arrived before the remote description was set + remoteCandidates.current.forEach((candidate) => { + peerConnection.addIceCandidate(candidate) + }) + }, [peerConnection, pendingOffer, signalingChannel]) + //* listen for answer to call ------------------------------------------------------------------- + useEffect(() => { + const answerEventListener = async (message: SignalingMessage) => { + if (message.type === "answer") { + await peerConnection.setRemoteDescription(new RTCSessionDescription(message.payload)) + } + } + signalingChannel?.on("message", answerEventListener) + return () => { + signalingChannel?.off("message", answerEventListener) + } + }, [peerConnection, signalingChannel]) + //* listen for ICE candidates ------------------------------------------------------------------- + useEffect(() => { + // send ice candidates to the other peer + const iceCandidateEventListener = (event: RTCIceCandidateEvent<"icecandidate">) => { + if (event.candidate) { + signalingChannel?.send({ type: "new-ice-candidate", payload: event.candidate }) + } + } + peerConnection.addEventListener("icecandidate", iceCandidateEventListener) + // receive ice candidates from the other peer + const newCandidateEventListener = (message: SignalingMessage) => { + if (message.type === "new-ice-candidate") { + const iceCandidate = new RTCIceCandidate(message.payload) + // some candidates might arrive before the remote description is set + // (we'll add them after the remote description is set) + if (peerConnection.remoteDescription == null) { + remoteCandidates.current.push(iceCandidate) + } else { + peerConnection.addIceCandidate(iceCandidate) + } + } + } + signalingChannel.on("message", newCandidateEventListener) + return () => { + peerConnection.removeEventListener("icecandidate", iceCandidateEventListener) + signalingChannel?.off("message", newCandidateEventListener) + } + }, [localClientId, peerConnection, signalingChannel]) + //* set up remote stream ------------------------------------------------------------------------ + useEffect(() => { + const trackEventListener = (event: RTCTrackEvent<"track">) => { + if (event.track) remoteStream.current.addTrack(event.track) + } + peerConnection.addEventListener("track", trackEventListener) + return () => { + peerConnection.removeEventListener("track", trackEventListener) + } + }, [localClientId, peerConnection]) + //* update call state --------------------------------------------------------------------------- + useEffect(() => { + const connectionStateChangeListener = () => { + switch (peerConnection.connectionState) { + case "connected": + setCallState("connected") + break + case "closed": + console.log(`${localClientId} closed connection`) + break + + default: + break + } + } + peerConnection.addEventListener("connectionstatechange", connectionStateChangeListener) + return () => { + peerConnection.removeEventListener("connectionstatechange", connectionStateChangeListener) + } + }, [localClientId, peerConnection]) + //* end call ------------------------------------------------------------------------------------ + const endCall = useCallback(async () => { + // tell the remote client to disconnect + signalingChannel?.disconnect() + reset() + }, [reset, signalingChannel]) + //* listen for remote disconnection ------------------------------------------------------------- + useEffect(() => { + const disconnectEventListener = () => { + reset() + } + signalingChannel?.on("disconnected", disconnectEventListener) + return () => { + signalingChannel?.off("disconnected", disconnectEventListener) + } + }, [reset, signalingChannel]) + //* controls ------------------------------------------------------------------------------------ + const toggleMute = useCallback(() => { + setIsMicEnabled((prev) => !prev) + }, []) + const toggleCamera = useCallback(() => { + setIsCameraEnabled((prev) => !prev) + }, []) + const toggleCameraMode = useCallback(() => { + localStream.current?.getVideoTracks()[0]?._switchCamera() + setIsFrontCamera((prev) => !prev) + }, []) + useEffect(() => { + localStream.current?.getAudioTracks().forEach((track) => { + track.enabled = isMicEnabled + }) + }, [isMicEnabled]) + useEffect(() => { + localStream.current?.getVideoTracks().forEach((track) => { + track.enabled = isCameraEnabled + }) + }, [isCameraEnabled]) + //* debug logs ---------------------------------------------------------------------------------- + useEffect(() => { + if (signalingChannel) { + signalingChannel.on("message", (message: SignalingMessage) => { + console.log(`currentUserId: ${localClientId}, received message`, message.type) + }) + } + peerConnection.addEventListener("connectionstatechange", () => { + console.log( + `currentUserId: ${localClientId}, connection state: ${peerConnection.connectionState}` + ) + }) + }, [localClientId, peerConnection, signalingChannel]) + + return { + callState, + localStream, + remoteStream, + makeCall, + acceptCall, + endCall, + isMicEnabled, + isCameraEnabled, + isFrontCamera, + toggleMute, + toggleCamera, + toggleCameraMode, + } +} diff --git a/react-native-expo-app/client/tsconfig.json b/react-native-expo-app/client/tsconfig.json new file mode 100644 index 0000000..956e0ec --- /dev/null +++ b/react-native-expo-app/client/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "expo/tsconfig.base", + "compilerOptions": { + "strict": true, + "baseUrl": ".", + "target": "ES6", + "lib": [ + "es6" + ] + }, +} \ No newline at end of file diff --git a/react-native-expo-app/docs/incoming-call.png b/react-native-expo-app/docs/incoming-call.png new file mode 100644 index 0000000..81e6fa6 Binary files /dev/null and b/react-native-expo-app/docs/incoming-call.png differ diff --git a/react-native-expo-app/docs/join.png b/react-native-expo-app/docs/join.png new file mode 100644 index 0000000..6cc57a0 Binary files /dev/null and b/react-native-expo-app/docs/join.png differ diff --git a/react-native-expo-app/docs/on-call.png b/react-native-expo-app/docs/on-call.png new file mode 100644 index 0000000..b699ec7 Binary files /dev/null and b/react-native-expo-app/docs/on-call.png differ diff --git a/react-native-expo-app/docs/outgoing-call.png b/react-native-expo-app/docs/outgoing-call.png new file mode 100644 index 0000000..a3f626e Binary files /dev/null and b/react-native-expo-app/docs/outgoing-call.png differ diff --git a/react-native-expo-app/server/.gitignore b/react-native-expo-app/server/.gitignore new file mode 100644 index 0000000..dc930cb --- /dev/null +++ b/react-native-expo-app/server/.gitignore @@ -0,0 +1,10 @@ + + +# node.js +# +node_modules/ +npm-debug.log +yarn-error.log + +# compiled output +dist/ diff --git a/react-native-expo-app/server/index.ts b/react-native-expo-app/server/index.ts new file mode 100644 index 0000000..d7fb0c7 --- /dev/null +++ b/react-native-expo-app/server/index.ts @@ -0,0 +1,60 @@ +import { createServer } from 'node:http' +import { Server } from 'socket.io' + +export type SignalingMessage = { type: string; payload: any } +type FullSignalingMessage = { + remoteClientId: string + message: SignalingMessage +} + +const server = createServer() +const io = new Server(server) + +io.on('connection', socket => { + const { localClientId } = socket.handshake.query + console.log('client connected', localClientId) + if (!localClientId) { + socket.disconnect() + console.warn('client disconnected due to invalid query params') + return + } + // join a room with the client id so that other clients can find it + socket.join(localClientId as string) + socket.on('connectTo', (remoteClientId: string) => { + console.log(`${localClientId} wants to connect to ${remoteClientId}`) + // check if room exists + if (!io.sockets.adapter.rooms.has(remoteClientId)) { + console.warn('remote client not found while connecting') + return + } + // send event to remote client + io.to(remoteClientId).emit('connectTo', localClientId) + }) + socket.on('disconnectFrom', (remoteClientId: string) => { + console.log(`${localClientId} wants to disconnect from ${remoteClientId}`) + // check if room exists + if (!io.sockets.adapter.rooms.has(remoteClientId)) { + console.warn('remote client not found while disconnecting') + return + } + // send event to remote client + io.to(remoteClientId).emit('disconnectFrom', localClientId) + }) + socket.on('message', (message: FullSignalingMessage) => { + const remoteClientId = message.remoteClientId + console.log( + `message from ${localClientId} to ${remoteClientId} | type: ${message.message.type}` + ) + // check if room exists + if (!io.sockets.adapter.rooms.has(remoteClientId)) { + console.warn('remote client not found while sending message') + return + } + // send message to remote client + io.to(remoteClientId).emit('message', message.message) + }) +}) + +server.listen(3000, () => { + console.log('server running at http://localhost:3000') +}) diff --git a/react-native-expo-app/server/package.json b/react-native-expo-app/server/package.json new file mode 100644 index 0000000..c691074 --- /dev/null +++ b/react-native-expo-app/server/package.json @@ -0,0 +1,23 @@ +{ + "name": "react-native-webrtc-expo-example-server", + "version": "1.0.0", + "description": "", + "main": "index.js", + "author": { + "name": "Mohammed Handa", + "email": "mohamadhenda@gmail.com", + "url": "https://mohammedhanda.com" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "start": "tsc && node dist/index.js" + }, + "dependencies": { + "socket.io": "^4.0.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "typescript": "^5.3.3" + }, + "private": true +} diff --git a/react-native-expo-app/server/tsconfig.json b/react-native-expo-app/server/tsconfig.json new file mode 100644 index 0000000..90438aa --- /dev/null +++ b/react-native-expo-app/server/tsconfig.json @@ -0,0 +1,65 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + /* Basic Options */ + // "incremental": true, /* Enable incremental compilation */ + "target": "es5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ + "module": "CommonJS", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ + // "lib": [], /* Specify library files to be included in the compilation. */ + // "allowJs": true, /* Allow javascript files to be compiled. */ + // "checkJs": true, /* Report errors in .js files. */ + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */ + // "declaration": true, /* Generates corresponding '.d.ts' file. */ + // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ + "sourceMap": true, /* Generates corresponding '.map' file. */ + // "outFile": "./", /* Concatenate and emit output to single file. */ + // "outDir": "./", /* Redirect output structure to the directory. */ + "outDir": "dist", /* Redirect output structure to the directory. */ + // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + // "composite": true, /* Enable project compilation */ + // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ + // "removeComments": true, /* Do not emit comments to output. */ + // "noEmit": true, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + /* Strict Type-Checking Options */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ + // "strictNullChecks": true, /* Enable strict null checks. */ + // "strictFunctionTypes": true, /* Enable strict checking of function types. */ + // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + /* Additional Checks */ + // "noUnusedLocals": true, /* Report errors on unused locals. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ + // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */ + /* Module Resolution Options */ + "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": [], /* Type declaration files to be included in compilation. */ + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + /* Experimental Options */ + // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + /* Advanced Options */ + "skipLibCheck": true, /* Skip type checking of declaration files. */ + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ + } +} \ No newline at end of file