Skip to content

Commit

Permalink
[feature] Video Rooms
Browse files Browse the repository at this point in the history
## Video rooms

Addresses: #1598

Initialize your room from the Admin panel, 
![Screenshot from 2023-09-13 09-02-45](https://github.com/restarone/violet_rails/assets/35935196/d88a90be-7d41-4676-b04d-19cf97cd5dee)

share the link with participants to join!
![Screenshot from 2023-09-13 09-00-42](https://github.com/restarone/violet_rails/assets/35935196/532c2f32-acd7-4fb5-9bdd-bad65a7daa30)


## How does it work?
It works by peers streaming to, and from each other. For more details and to 

learn about WebRTC, Signalling servers (TURN/STUN) and ICE candidates, see here: https://www.youtube.com/watch?v=WmR9IMUD_CY


guide: https://github.com/domchristie/webrtc-hotwire-rails

Todo's: 

1. ability for participants to mute audio / stop video 
2. fix flakiness in new participants connecting to already-joined peers (newly joined peers dont see existing participants until they refresh the page and join again)
3. UI improvements, see: https://github.com/Alicunde/Videoconference-Dish-CSS-JS
  • Loading branch information
donrestarone authored Sep 16, 2023
1 parent a1a6551 commit 21184ab
Show file tree
Hide file tree
Showing 20 changed files with 6,259 additions and 0 deletions.
5,626 changes: 5,626 additions & 0 deletions app/assets/javascripts/libraries/adapter.js

Large diffs are not rendered by default.

29 changes: 29 additions & 0 deletions app/channels/application_cable/connection.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,33 @@
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_client

def connect
self.current_client = find_client
bind_user_to_client(self.current_client)
bind_visit_to_client((self.current_client))
end

private

def bind_visit_to_client(client)
if cookies[:cookies_accepted]
client.visit_id = cookies[:ahoy_visitor]
client.visitor_id = cookies[:ahoy_visit]
end
end

def bind_user_to_client(client)
user = env['warden']&.user
if user
client.user_id = user.id
else
nil
end
end

def find_client
Client.new(id: cookies.encrypted[:client_id])
end
end
end
30 changes: 30 additions & 0 deletions app/channels/room_channel.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
class RoomChannel < ApplicationCable::Channel
def subscribed
@room = find_room
stream_for @room
broadcast_to @room, { type: 'ping', from: params[:client_id] }
end

def unsubscribed
Turbo::StreamsChannel.broadcast_remove_to(
find_room,
target: "medium_#{current_client.id}"
)
end

def greet(data)
user = User.find_by(id: current_client.user_id)
Turbo::StreamsChannel.broadcast_append_to(
data['to'],
target: 'media',
partial: 'media/medium',
locals: { client_id: data['from'], user: user}
)
end

private

def find_room
Room.new(id: params[:id])
end
end
9 changes: 9 additions & 0 deletions app/channels/signaling_channel.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class SignalingChannel < ApplicationCable::Channel
def subscribed
stream_for Room.new(id: params[:id])
end

def signal(data)
broadcast_to(Room.new(id: params[:id]), data)
end
end
9 changes: 9 additions & 0 deletions app/controllers/comfy/admin/rooms_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class Comfy::Admin::RoomsController < Comfy::Admin::Cms::BaseController
def new
render 'rooms/new'
end

def create
redirect_to room_path(SecureRandom.uuid)
end
end
10 changes: 10 additions & 0 deletions app/controllers/rooms_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
class RoomsController < ApplicationController
def show
@client = Client.new(id: SecureRandom.uuid)
cookies.encrypted[:client_id] = @client.id
@room = Room.new(id: params[:id])

@user = current_user
@visit = current_visit
end
end
17 changes: 17 additions & 0 deletions app/javascript/controllers/medium_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Controller } from '@hotwired/stimulus'

export default class MediumController extends Controller {
connect () {
this.reRenderMediaElement()
}

// Fix potentially blank videos due to autoplay rules?
reRenderMediaElement () {
const mediaElement = this.mediaElementTarget
const clone = mediaElement.cloneNode(true)
mediaElement.parentNode.insertBefore(clone, mediaElement)
mediaElement.remove()
}
}

MediumController.targets = ['mediaElement']
147 changes: 147 additions & 0 deletions app/javascript/controllers/room_controller.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { Controller } from '@hotwired/stimulus'
import Client from 'models/client'
import WebrtcNegotiation from 'models/webrtc_negotiation'
import RoomSubscription from 'subscriptions/room_subscription'
import Signaller from 'subscriptions/signaling_subscription'

export default class RoomController extends Controller {
connect() {
this.clients = {}
this.client = new Client(this.clientIdValue)

this.subscription = new RoomSubscription({
delegate: this,
id: this.idValue,
clientId: this.client.id
})

this.signaller = new Signaller({
delegate: this,
id: this.idValue,
clientId: this.client.id
})

this.client.on('iceConnection:checking', ({ detail: { otherClient } }) => {
this.startStreamingTo(otherClient)
})
}

async enter () {
try {
const constraints = { audio: true, video: true }
this.client.stream = await navigator.mediaDevices.getUserMedia(constraints)
this.localMediumTarget.srcObject = this.client.stream
this.localMediumTarget.muted = true // Keep muted on Firefox
this.enterTarget.hidden = true

this.subscription.start()
this.signaller.start()
} catch (error) {
console.error(error)
}
}

greetNewClient ({ from }) {
const otherClient = this.findOrCreateClient(from)
otherClient.newcomer = true
this.subscription.greet({ to: otherClient.id, from: this.client.id })
}

remoteMediumTargetConnected (element) {
const clientId = element.id.replace('medium_', '')
this.negotiateConnection(clientId)
}

remoteMediumTargetDisconnected (element) {
const clientId = element.id.replace('medium_', '')
this.teardownClient(clientId)
}

negotiateConnection (clientId) {
const otherClient = this.findOrCreateClient(clientId)

// Be polite to newcomers!
const polite = !!otherClient.newcomer

otherClient.negotiation = this.createNegotiation({ otherClient, polite })

// The polite client sets up the negotiation last, so we can start streaming
// The impolite client signals to the other client that it's ready
if (polite) {
this.startStreamingTo(otherClient)
} else {
this.subscription.greet({ to: otherClient.id, from: this.client.id })
}
}

teardownClient (clientId) {
this.clients[clientId].stop()
delete this.clients[clientId]
}

createNegotiation ({ otherClient, polite }) {
const negotiation = new WebrtcNegotiation({
signaller: this.signaller,
client: this.client,
otherClient: otherClient,
polite
})

otherClient.on('track', ({ detail }) => {
this.startStreamingFrom(otherClient.id, detail)
})

return negotiation
}

startStreamingTo (otherClient) {
this.client.streamTo(otherClient)
}

startStreamingFrom (id, { track, streams: [stream] }) {
const remoteMediaElement = this.findRemoteMediaElement(id)
if (!remoteMediaElement.srcObject) {
remoteMediaElement.srcObject = stream
}
}

findOrCreateClient (id) {
return this.clients[id] || (this.clients[id] = new Client(id))
}

findRemoteMediaElement (clientId) {
const target = this.remoteMediumTargets.find(
target => target.id === `medium_${clientId}`
)
return target ? target.querySelector('video') : null
}

negotiationFor (id) {
return this.clients[id].negotiation
}

// RoomSubscription Delegate

roomPinged (data) {
this.greetNewClient(data)
}

// Signaler Delegate

sdpDescriptionReceived ({ from, description }) {
this.negotiationFor(from).setDescription(description)
}

iceCandidateReceived ({ from, candidate }) {
this.negotiationFor(from).addCandidate(candidate)
}

negotiationRestarted ({ from }) {
const negotiation = this.negotiationFor(from)
negotiation.restart()
negotiation.createOffer()
}
}

RoomController.values = { id: String, clientId: String }
RoomController.targets = ['localMedium', 'remoteMedium', 'enter']
43 changes: 43 additions & 0 deletions app/javascript/models/client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
export default class Client {
constructor (id) {
this.callbacks = {}
this.id = id
}

get peerConnection () {
return this.negotiation && this.negotiation.peerConnection
}

streamTo (otherClient) {
if (!otherClient.streaming) {
this.stream.getTracks().forEach(track => {
otherClient.peerConnection.addTrack(track, this.stream)
})
otherClient.streaming = true
}
}

on (name, callback) {
const names = name.split(' ')
names.forEach((name) => {
this.callbacks[name] = this.callbacks[name] || []
this.callbacks[name].push(callback)
})
}

broadcast (name, data) {
(this.callbacks[name] || []).forEach(
callback => callback.call(null, { type: name, detail: data })
)
}

off (name) {
if (name) return delete this.callbacks[name]
else this.callbacks = {}
}

stop () {
this.off()
this.negotiation.stop()
}
}
Loading

0 comments on commit 21184ab

Please sign in to comment.