Add video chat

This commit is contained in:
Jesse C. Fisher 2023-08-11 12:45:59 +00:00 committed by icwizardmonke
parent 327028f3a3
commit c37157781f
21 changed files with 3955 additions and 1 deletions

1
.gitignore vendored
View File

@ -83,3 +83,4 @@ pickle-email-*.html
# Environment files that may contain sensitive data
.env
.powenv
node_modules/

View File

@ -15,6 +15,8 @@ Development Environment Setup
TODO
----
- change from google ice server ./frontend/components/video_util.js:6
- Add video chat https://medium.com/@nicolas.e.schneider/videochat-in-under-30-a-rails-react-tutorial-534930c6cd96
- Refactor seat.jsx buttons in to a <form>.

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,13 @@
// Action Cable provides the framework to deal with WebSockets in Rails.
// You can generate new channels where WebSocket features live using the `rails generate channel` command.
//
//= require action_cable
//= require_self
//= require_tree ./channels
(function() {
this.App || (this.App = {});
App.cable = ActionCable.createConsumer();
}).call(this);

View File

@ -0,0 +1,3 @@
# Place all the behaviors and hooks related to the matching controller here.
# All this logic will automatically be available in application.js.
# You can use CoffeeScript in this file: http://coffeescript.org/

View File

@ -0,0 +1,9 @@
#App.call = App.cable.subscriptions.create "CallChannel",
# connected: ->
# # Called when the subscription is ready for use on the server
#
# disconnected: ->
# # Called when the subscription has been terminated by the server
#
# received: (data) ->
# # Called when there's incoming data on the websocket for this channel

View File

@ -0,0 +1,3 @@
// Place all the styles related to the Calls controller here.
// They will automatically be included in application.css.
// You can use Sass (SCSS) here: https://sass-lang.com/

View File

@ -0,0 +1,4 @@
module ApplicationCable
class Channel < ActionCable::Channel::Base
end
end

View File

@ -0,0 +1,4 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
end
end

View File

@ -0,0 +1,9 @@
class CallChannel < ApplicationCable::Channel
def subscribed
stream_from "call_channel"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
end
end

View File

@ -1,7 +1,8 @@
class ApplicationController < ActionController::Base
# Prevent CSRF attacks by raising an exception.
# For APIs, you may want to use :null_session instead.
protect_from_forgery with: :exception
# protect_from_forgery with: :exception
protect_from_forgery unless: -> { request.format.json? }
after_action :flash_to_http_header
if (Rails.env.development? || Rails.env.test?)

View File

@ -0,0 +1,12 @@
class CallsController < ApplicationController
before_action :skip_authorization
def create
head :no_content
ActionCable.server.broadcast("call_channel", call_params)
end
private
def call_params
params.permit(:call, :type, :from, :to, :sdp)
end
end

View File

@ -0,0 +1,2 @@
module CallsHelper
end

View File

@ -0,0 +1,3 @@
%main#root
%h1 If you're seeing this, something broke
-# , props = {id: @game.id, current_user: current_user}, html_options = {id: "game-component-container"}

View File

@ -51,4 +51,7 @@ Rails.application.routes.draw do
resources :users
get '/loaderio-456644f4db1fcd1e8578be882d2fc476.txt', to: redirect('/assets/loaderio-456644f4db1fcd1e8578be882d2fc476.txt')
get '/loaderio-eb4afd672bf5b9af467fbadcba243f26.txt', to: redirect('/assets/loaderio-eb4afd672bf5b9af467fbadcba243f26.txt')
get '/calls', to: 'calls#root'
resources :calls, only: :create
mount ActionCable.server, at: '/cable'
end

7
frontend/App.jsx Normal file
View File

@ -0,0 +1,7 @@
import React from 'react';
import VideoCall from './components/VideoCall';
import ReactDOM from 'react-dom';
document.addEventListener("DOMContentLoaded", () => {
const root = document.getElementById("root");
ReactDOM.render(<VideoCall />, root)
});

View File

@ -0,0 +1,151 @@
import React from 'react';
import { broadcastData, JOIN_CALL, LEAVE_CALL, EXCHANGE, ice } from './video_util.js';
class VideoCall extends React.Component{
constructor(props){
super(props);
this.pcPeers = {};
this.userId = Math.floor(Math.random() * 10000);
this.joinCall = this.joinCall.bind(this);
this.leaveCall = this.leaveCall.bind(this);
}
componentDidMount(){
this.remoteVideoContainer =
document.getElementById("remote-video-container")
navigator.mediaDevices.getUserMedia({audio: false, video: true})
.then(stream => {
this.localStream = stream;
document.getElementById("local-video").srcObject = stream;
}).catch(error => { console.log(error)});
}
join(data){ this.createPC(data.from, true) }
joinCall(e){
App.cable.subscriptions.create(
{ channel: "CallChannel" },
{
connected: () => {
broadcastData({ type: JOIN_CALL, from: this.userId});
},
received: data => {
console.log("RECEIVED: ", data);
if (data.from === this.userId) return;
switch(data.type){
case JOIN_CALL:
return this.join(data);
case EXCHANGE:
if (data.to !== this.userId) return;
return this.exchange(data);
case LEAVE_CALL:
return this.removeUser(data);
default:
return;
}
}
});
}
createPC(userId, offerBool){
const pc = new RTCPeerConnection(ice);
this.pcPeers[userId] = pc;
this.localStream.getTracks()
.forEach(track => pc.addTrack(track, this.localStream));
if (offerBool) {
pc.createOffer().then(offer => {
pc.setLocalDescription(offer).then(() => {
setTimeout( () => {
broadcastData({
type: EXCHANGE,
from: this.userId,
to: userId,
sdp: JSON.stringify(pc.localDescription),
});
}, 0);
});
});
}
pc.onicecandidate = (e) => {
broadcastData({
type: EXCHANGE,
from: this.userId,
to: userId,
sdp: JSON.stringify(e.candidate)
})
}
pc.ontrack = (e) => {
const remoteVid = document.createElement("video");
remoteVid.id = `remoteVideoContainer+${userId}`;
remoteVid.autoplay = "autoplay";
remoteVid.srcObject = e.streams[0];
this.remoteVideoContainer.appendChild(remoteVid);
}
pc.oniceconnectionstatechange = (e) => {
if (pc.iceConnectionState === 'disconnected'){
broadcastData({ type: LEAVE_CALL, from: userId });
}
}
return pc;
}
exchange(data){
let pc;
if(this.pcPeers[data.from]){
pc = this.pcPeers[data.from];
} else{
pc = this.createPC(data.from, false);
}
if (data.candidate){
let candidate = JSON.parse(data.candidate)
pc.addIceCandidate(new RTCIceCandidate(candidate))
}
if(data.sdp){
const sdp = JSON.parse(data.sdp);
if(sdp && !sdp.candidate){
pc.setRemoteDescription(sdp).then( () =>{
if (sdp.type === 'offer'){
pc.createAnswer().then(answer => {
pc.setLocalDescription(answer)
.then( () => {
broadcastData({
type: EXCHANGE,
from: this.userId,
to: data.from,
sdp: JSON.stringify(pc.localDescription)
});
});
});
}
});
}
}
}
leaveCall(e){
const pcKeys = Object.keys(this.pcPeers);
for (let i = 0; i < pcKeys.length; i++) {
this.pcPeers[pcKeys[i]].close();
}
this.pcPeers = {};
this.localVideo.srcObject.getTracks()
.forEach(function (track) { track.stop(); })
this.localVideo.srcObject = null;
App.cable.subscriptions.subscriptions = [];
this.remoteVideoContainer.innerHTML = "";
broadcastData({ type: LEAVE_CALL, from: this.userId });
}
removeUser(data){
let video = document.getElementById(`remoteVideoContainer+${data.from}`);
video && video.remove();
let peers = this.pcPeers
delete peers[data.from]
}
render(){
return(
<div className="VideoCall">
<div id="remote-video-container"></div>
<video id="local-video" autoPlay></video>
<button onClick={this.joinCall}>Join Call</button>
<button onClick={this.leaveCall}>Leave Call</button>
</div>)
}
}
export default VideoCall;

View File

@ -0,0 +1,16 @@
export const JOIN_CALL = "JOIN_CALL";
export const EXCHANGE = "EXCHANGE";
export const LEAVE_CALL = "LEAVE_CALL";
export const ice = { iceServers: [
{
urls: "stun:stun2.l.google.com:19302"
}
]};
export const broadcastData = data => {
fetch("calls", {
method: "POST",
body: JSON.stringify(data),
headers: {"content-type": "application/json"}
}
);
};

3475
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@ -0,0 +1,26 @@
{
"name": "thirteen-tien-len",
"version": "1.0.0",
"description": "Thirteen Tien Len ================",
"main": "index.js",
"directories": {
"lib": "lib"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"webpack": "webpack --mode=development --watch"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@babel/core": "^7.22.10",
"@babel/preset-env": "^7.22.10",
"@babel/preset-react": "^7.22.5",
"babel-loader": "^9.1.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4"
}
}

20
webpack.config.js Normal file
View File

@ -0,0 +1,20 @@
var path = require('path');
module.exports = {
entry: "./frontend/App.jsx",
output: {
path: path.resolve(__dirname, 'app', 'assets', 'javascripts'),
filename: "bundle.js"
},
module: {
rules: [{
test: [/\.jsx?$/],
exclude: /(node_modules)/,
use: {
loader: 'babel-loader',
options: { presets: ['@babel/env', '@babel/react'] }
},
}]
},
devtool: 'eval-source-map',
resolve: { extensions: ['.js', '.jsx', '*']}
};