-
Notifications
You must be signed in to change notification settings - Fork 43
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
## 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
1 parent
a1a6551
commit 21184ab
Showing
20 changed files
with
6,259 additions
and
0 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() | ||
} | ||
} |
Oops, something went wrong.