Add video chat
This commit is contained in:
parent
327028f3a3
commit
c37157781f
1
.gitignore
vendored
1
.gitignore
vendored
@ -83,3 +83,4 @@ pickle-email-*.html
|
|||||||
# Environment files that may contain sensitive data
|
# Environment files that may contain sensitive data
|
||||||
.env
|
.env
|
||||||
.powenv
|
.powenv
|
||||||
|
node_modules/
|
||||||
|
|||||||
@ -15,6 +15,8 @@ Development Environment Setup
|
|||||||
TODO
|
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
|
- 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>.
|
- Refactor seat.jsx buttons in to a <form>.
|
||||||
|
|||||||
190
app/assets/javascripts/bundle.js
Normal file
190
app/assets/javascripts/bundle.js
Normal file
File diff suppressed because one or more lines are too long
13
app/assets/javascripts/cable.js
Normal file
13
app/assets/javascripts/cable.js
Normal 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);
|
||||||
3
app/assets/javascripts/calls.coffee
Normal file
3
app/assets/javascripts/calls.coffee
Normal 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/
|
||||||
9
app/assets/javascripts/channels/call.coffee
Normal file
9
app/assets/javascripts/channels/call.coffee
Normal 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
|
||||||
3
app/assets/stylesheets/calls.scss
Normal file
3
app/assets/stylesheets/calls.scss
Normal 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/
|
||||||
4
app/channels/application_cable/channel.rb
Normal file
4
app/channels/application_cable/channel.rb
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
module ApplicationCable
|
||||||
|
class Channel < ActionCable::Channel::Base
|
||||||
|
end
|
||||||
|
end
|
||||||
4
app/channels/application_cable/connection.rb
Normal file
4
app/channels/application_cable/connection.rb
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
module ApplicationCable
|
||||||
|
class Connection < ActionCable::Connection::Base
|
||||||
|
end
|
||||||
|
end
|
||||||
9
app/channels/call_channel.rb
Normal file
9
app/channels/call_channel.rb
Normal 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
|
||||||
@ -1,7 +1,8 @@
|
|||||||
class ApplicationController < ActionController::Base
|
class ApplicationController < ActionController::Base
|
||||||
# Prevent CSRF attacks by raising an exception.
|
# Prevent CSRF attacks by raising an exception.
|
||||||
# For APIs, you may want to use :null_session instead.
|
# 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
|
after_action :flash_to_http_header
|
||||||
|
|
||||||
if (Rails.env.development? || Rails.env.test?)
|
if (Rails.env.development? || Rails.env.test?)
|
||||||
|
|||||||
12
app/controllers/calls_controller.rb
Normal file
12
app/controllers/calls_controller.rb
Normal 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
|
||||||
2
app/helpers/calls_helper.rb
Normal file
2
app/helpers/calls_helper.rb
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
module CallsHelper
|
||||||
|
end
|
||||||
3
app/views/calls/root.html.haml
Normal file
3
app/views/calls/root.html.haml
Normal 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"}
|
||||||
@ -51,4 +51,7 @@ Rails.application.routes.draw do
|
|||||||
resources :users
|
resources :users
|
||||||
get '/loaderio-456644f4db1fcd1e8578be882d2fc476.txt', to: redirect('/assets/loaderio-456644f4db1fcd1e8578be882d2fc476.txt')
|
get '/loaderio-456644f4db1fcd1e8578be882d2fc476.txt', to: redirect('/assets/loaderio-456644f4db1fcd1e8578be882d2fc476.txt')
|
||||||
get '/loaderio-eb4afd672bf5b9af467fbadcba243f26.txt', to: redirect('/assets/loaderio-eb4afd672bf5b9af467fbadcba243f26.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
|
end
|
||||||
|
|||||||
7
frontend/App.jsx
Normal file
7
frontend/App.jsx
Normal 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)
|
||||||
|
});
|
||||||
151
frontend/components/VideoCall.jsx
Normal file
151
frontend/components/VideoCall.jsx
Normal 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;
|
||||||
16
frontend/components/video_util.js
Normal file
16
frontend/components/video_util.js
Normal 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
3475
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
package.json
Normal file
26
package.json
Normal 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
20
webpack.config.js
Normal 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', '*']}
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user