diff --git a/app/assets/javascripts/signaling-server.js b/app/assets/javascripts/signaling-server.js new file mode 100644 index 0000000..55f59d1 --- /dev/null +++ b/app/assets/javascripts/signaling-server.js @@ -0,0 +1,196 @@ +// Broadcast Types + +class Signaling {} + +const JOIN_ROOM = "JOIN_ROOM"; +const EXCHANGE = "EXCHANGE"; +const REMOVE_USER = "REMOVE_USER"; + +// DOM Elements +let currentUser; +let localVideo; +let remoteVideoContainer; + +// Objects +let pcPeers = {}; // peer connection +let localstream; + +window.onload = () => { + currentUser = document.getElementById("current-user").innerHTML; + localVideo = document.getElementById("local-video"); + remoteVideoContainer = document.getElementById("remote-video-container"); +}; + +// Ice Credentials +const ice = { iceServers: [{urls: ['stun:stun.l.google.com:19302', 'stun:stun.1.google.com:19302']}]}; + +// Initialize user's own video +document.onreadystatechange = async () => { + if (document.readyState === "interactive") { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + video: true + }) + + localstream = stream; + localVideo.srcObject = stream + localVideo.muted = true + } catch (e) { console.error(e); } + } +}; + + +const handleJoinSession = async () => { + App.session = await App.cable.subscriptions.create("VideoSessionChannel", { + connected: () => { + broadcastData({ + type: JOIN_ROOM, + from: currentUser + }); + }, + received: data => { + console.log("received", data); + if (data.from === currentUser) return; + switch (data.type) { + case JOIN_ROOM: + return joinRoom(data); + case EXCHANGE: + if (data.to !== currentUser) return; + return exchange(data); + case REMOVE_USER: + return removeUser(data); + default: + return; + } + } + }); +}; + +const handleLeaveSession = () => { + for (user in pcPeers) { + pcPeers[user].close(); + } + pcPeers = {}; + + App.session.unsubscribe(); + + remoteVideoContainer.innerHTML = ""; + + broadcastData({ + type: REMOVE_USER, + from: currentUser + }); +}; + +const joinRoom = data => { + createPC(data.from, true); +}; + +const removeUser = data => { + console.log("removing user", data.from); + let video = document.getElementById(`remoteVideoContainer+${data.from}`); + video && video.remove(); + delete pcPeers[data.from]; +}; + + +const broadcastData = data => { + fetch("sessions", { + method: "POST", + body: JSON.stringify(data), + headers: { "content-type": "application/json" } + }); +}; + +const createPC = (userId, isOffer) => { + let pc = new RTCPeerConnection(ice); + pcPeers[userId] = pc; + pc.addStream(localstream); + + if (isOffer) { + pc + .createOffer() + .then(offer => { + pc.setLocalDescription(offer); + broadcastData({ + type: EXCHANGE, + from: currentUser, + to: userId, + sdp: JSON.stringify(pc.localDescription) + }); + }) + .catch(logError); + } + + pc.onicecandidate = event => { + if (event.candidate) { + broadcastData({ + type: EXCHANGE, + from: currentUser, + to: userId, + candidate: JSON.stringify(event.candidate) + }); + } + }; + + pc.onaddstream = event => { + const element = document.createElement("video"); + element.id = `remoteVideoContainer+${userId}`; // why is the userId being interpolated? + element.autoplay = "autoplay"; + element.srcObject = event.stream; + remoteVideoContainer.appendChild(element); + }; + + pc.oniceconnectionstatechange = event => { + if (pc.iceConnectionState == "disconnected") { + console.log("Disconnected:", userId); + broadcastData({ + type: REMOVE_USER, + from: userId + }); + } + }; + + return pc; +}; + + +const exchange = data => { + let pc; + + if (!pcPeers[data.from]) { + pc = createPC(data.from, false); + } else { + pc = pcPeers[data.from]; + } + + if (data.candidate) { + pc + .addIceCandidate(new RTCIceCandidate(JSON.parse(data.candidate))) + .then(() => console.log("Ice candidate added")) + .catch(logError); + } + + if (data.sdp) { + sdp = JSON.parse(data.sdp); + pc + .setRemoteDescription(new RTCSessionDescription(sdp)) + .then(() => { + if (sdp.type === "offer") { + pc.createAnswer().then(answer => { + pc.setLocalDescription(answer); + broadcastData({ + type: EXCHANGE, + from: currentUser, + to: data.from, + sdp: JSON.stringify(pc.localDescription) + }); + }); + } + }) + .catch(logError); + } +}; + +const logError = error => console.warn("Whoops! Error:", error); diff --git a/app/assets/stylesheets/pages/_home.scss b/app/assets/stylesheets/pages/_home.scss index e42c883..c569eb8 100755 --- a/app/assets/stylesheets/pages/_home.scss +++ b/app/assets/stylesheets/pages/_home.scss @@ -1 +1,6 @@ // Specific CSS for your home-page +video { + transform: rotateY(180deg); + -webkit-transform:rotateY(180deg); /* Safari and Chrome */ + -moz-transform:rotateY(180deg); /* Firefox */ +} diff --git a/app/channels/video_session_channel.rb b/app/channels/video_session_channel.rb index 174da47..85de7db 100644 --- a/app/channels/video_session_channel.rb +++ b/app/channels/video_session_channel.rb @@ -1,6 +1,7 @@ class VideoSessionChannel < ApplicationCable::Channel def subscribed # video session + stream_from "video_session_channel" # stream_from "chat_room_#{params[:chat_room_id]}" end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 4e5617f..9264647 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,4 +1,5 @@ class ApplicationController < ActionController::Base protect_from_forgery with: :exception before_action :authenticate_user! + protect_from_forgery unless: -> { request.format.json? } # Only accept json end diff --git a/app/controllers/video_sessions_controller.rb b/app/controllers/video_sessions_controller.rb new file mode 100644 index 0000000..0e4e4a8 --- /dev/null +++ b/app/controllers/video_sessions_controller.rb @@ -0,0 +1,15 @@ +class VideoSessionsController < ApplicationController + def create + # HTTP status code 200 with an empty body + head :no_content + ActionCable.server.broadcast "video_session_channel", session_params + end + + private + + def session_params + # SDP = Session description protocol (codec info from client) + # Candidate = ICE candidates (e.g. TURN and STUN server) + params.permit(:type, :from, :to, :sdp, :candidate) + end +end diff --git a/app/views/pages/home.html.erb b/app/views/pages/home.html.erb index 3453cf2..b3f9343 100644 --- a/app/views/pages/home.html.erb +++ b/app/views/pages/home.html.erb @@ -1,2 +1,18 @@ -
Find me in app/views/pages/home.html.erb
+Find me in app/views/video_sessions/create.html.erb
diff --git a/config/routes.rb b/config/routes.rb index 343a62b..640a223 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,10 @@ Rails.application.routes.draw do + get 'video_sessions/create' devise_for :users root to: 'pages#home' + + post '/sessions', to: 'video_sessions#create' + + mount ActionCable.server, at: '/cable' # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html end diff --git a/test/controllers/video_sessions_controller_test.rb b/test/controllers/video_sessions_controller_test.rb new file mode 100644 index 0000000..ff0f94e --- /dev/null +++ b/test/controllers/video_sessions_controller_test.rb @@ -0,0 +1,9 @@ +require 'test_helper' + +class VideoSessionsControllerTest < ActionDispatch::IntegrationTest + test "should get create" do + get video_sessions_create_url + assert_response :success + end + +end