Skip to content

Commit 21184ab

Browse files
authored
[feature] Video Rooms
## 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
1 parent a1a6551 commit 21184ab

File tree

20 files changed

+6259
-0
lines changed

20 files changed

+6259
-0
lines changed

app/assets/javascripts/libraries/adapter.js

Lines changed: 5626 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,33 @@
11
module ApplicationCable
22
class Connection < ActionCable::Connection::Base
3+
identified_by :current_client
4+
5+
def connect
6+
self.current_client = find_client
7+
bind_user_to_client(self.current_client)
8+
bind_visit_to_client((self.current_client))
9+
end
10+
11+
private
12+
13+
def bind_visit_to_client(client)
14+
if cookies[:cookies_accepted]
15+
client.visit_id = cookies[:ahoy_visitor]
16+
client.visitor_id = cookies[:ahoy_visit]
17+
end
18+
end
19+
20+
def bind_user_to_client(client)
21+
user = env['warden']&.user
22+
if user
23+
client.user_id = user.id
24+
else
25+
nil
26+
end
27+
end
28+
29+
def find_client
30+
Client.new(id: cookies.encrypted[:client_id])
31+
end
332
end
433
end

app/channels/room_channel.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
class RoomChannel < ApplicationCable::Channel
2+
def subscribed
3+
@room = find_room
4+
stream_for @room
5+
broadcast_to @room, { type: 'ping', from: params[:client_id] }
6+
end
7+
8+
def unsubscribed
9+
Turbo::StreamsChannel.broadcast_remove_to(
10+
find_room,
11+
target: "medium_#{current_client.id}"
12+
)
13+
end
14+
15+
def greet(data)
16+
user = User.find_by(id: current_client.user_id)
17+
Turbo::StreamsChannel.broadcast_append_to(
18+
data['to'],
19+
target: 'media',
20+
partial: 'media/medium',
21+
locals: { client_id: data['from'], user: user}
22+
)
23+
end
24+
25+
private
26+
27+
def find_room
28+
Room.new(id: params[:id])
29+
end
30+
end

app/channels/signaling_channel.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class SignalingChannel < ApplicationCable::Channel
2+
def subscribed
3+
stream_for Room.new(id: params[:id])
4+
end
5+
6+
def signal(data)
7+
broadcast_to(Room.new(id: params[:id]), data)
8+
end
9+
end
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class Comfy::Admin::RoomsController < Comfy::Admin::Cms::BaseController
2+
def new
3+
render 'rooms/new'
4+
end
5+
6+
def create
7+
redirect_to room_path(SecureRandom.uuid)
8+
end
9+
end

app/controllers/rooms_controller.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
class RoomsController < ApplicationController
2+
def show
3+
@client = Client.new(id: SecureRandom.uuid)
4+
cookies.encrypted[:client_id] = @client.id
5+
@room = Room.new(id: params[:id])
6+
7+
@user = current_user
8+
@visit = current_visit
9+
end
10+
end
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { Controller } from '@hotwired/stimulus'
2+
3+
export default class MediumController extends Controller {
4+
connect () {
5+
this.reRenderMediaElement()
6+
}
7+
8+
// Fix potentially blank videos due to autoplay rules?
9+
reRenderMediaElement () {
10+
const mediaElement = this.mediaElementTarget
11+
const clone = mediaElement.cloneNode(true)
12+
mediaElement.parentNode.insertBefore(clone, mediaElement)
13+
mediaElement.remove()
14+
}
15+
}
16+
17+
MediumController.targets = ['mediaElement']
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { Controller } from '@hotwired/stimulus'
2+
import Client from 'models/client'
3+
import WebrtcNegotiation from 'models/webrtc_negotiation'
4+
import RoomSubscription from 'subscriptions/room_subscription'
5+
import Signaller from 'subscriptions/signaling_subscription'
6+
7+
export default class RoomController extends Controller {
8+
connect() {
9+
this.clients = {}
10+
this.client = new Client(this.clientIdValue)
11+
12+
this.subscription = new RoomSubscription({
13+
delegate: this,
14+
id: this.idValue,
15+
clientId: this.client.id
16+
})
17+
18+
this.signaller = new Signaller({
19+
delegate: this,
20+
id: this.idValue,
21+
clientId: this.client.id
22+
})
23+
24+
this.client.on('iceConnection:checking', ({ detail: { otherClient } }) => {
25+
this.startStreamingTo(otherClient)
26+
})
27+
}
28+
29+
async enter () {
30+
try {
31+
const constraints = { audio: true, video: true }
32+
this.client.stream = await navigator.mediaDevices.getUserMedia(constraints)
33+
this.localMediumTarget.srcObject = this.client.stream
34+
this.localMediumTarget.muted = true // Keep muted on Firefox
35+
this.enterTarget.hidden = true
36+
37+
this.subscription.start()
38+
this.signaller.start()
39+
} catch (error) {
40+
console.error(error)
41+
}
42+
}
43+
44+
greetNewClient ({ from }) {
45+
const otherClient = this.findOrCreateClient(from)
46+
otherClient.newcomer = true
47+
this.subscription.greet({ to: otherClient.id, from: this.client.id })
48+
}
49+
50+
remoteMediumTargetConnected (element) {
51+
const clientId = element.id.replace('medium_', '')
52+
this.negotiateConnection(clientId)
53+
}
54+
55+
remoteMediumTargetDisconnected (element) {
56+
const clientId = element.id.replace('medium_', '')
57+
this.teardownClient(clientId)
58+
}
59+
60+
negotiateConnection (clientId) {
61+
const otherClient = this.findOrCreateClient(clientId)
62+
63+
// Be polite to newcomers!
64+
const polite = !!otherClient.newcomer
65+
66+
otherClient.negotiation = this.createNegotiation({ otherClient, polite })
67+
68+
// The polite client sets up the negotiation last, so we can start streaming
69+
// The impolite client signals to the other client that it's ready
70+
if (polite) {
71+
this.startStreamingTo(otherClient)
72+
} else {
73+
this.subscription.greet({ to: otherClient.id, from: this.client.id })
74+
}
75+
}
76+
77+
teardownClient (clientId) {
78+
this.clients[clientId].stop()
79+
delete this.clients[clientId]
80+
}
81+
82+
createNegotiation ({ otherClient, polite }) {
83+
const negotiation = new WebrtcNegotiation({
84+
signaller: this.signaller,
85+
client: this.client,
86+
otherClient: otherClient,
87+
polite
88+
})
89+
90+
otherClient.on('track', ({ detail }) => {
91+
this.startStreamingFrom(otherClient.id, detail)
92+
})
93+
94+
return negotiation
95+
}
96+
97+
startStreamingTo (otherClient) {
98+
this.client.streamTo(otherClient)
99+
}
100+
101+
startStreamingFrom (id, { track, streams: [stream] }) {
102+
const remoteMediaElement = this.findRemoteMediaElement(id)
103+
if (!remoteMediaElement.srcObject) {
104+
remoteMediaElement.srcObject = stream
105+
}
106+
}
107+
108+
findOrCreateClient (id) {
109+
return this.clients[id] || (this.clients[id] = new Client(id))
110+
}
111+
112+
findRemoteMediaElement (clientId) {
113+
const target = this.remoteMediumTargets.find(
114+
target => target.id === `medium_${clientId}`
115+
)
116+
return target ? target.querySelector('video') : null
117+
}
118+
119+
negotiationFor (id) {
120+
return this.clients[id].negotiation
121+
}
122+
123+
// RoomSubscription Delegate
124+
125+
roomPinged (data) {
126+
this.greetNewClient(data)
127+
}
128+
129+
// Signaler Delegate
130+
131+
sdpDescriptionReceived ({ from, description }) {
132+
this.negotiationFor(from).setDescription(description)
133+
}
134+
135+
iceCandidateReceived ({ from, candidate }) {
136+
this.negotiationFor(from).addCandidate(candidate)
137+
}
138+
139+
negotiationRestarted ({ from }) {
140+
const negotiation = this.negotiationFor(from)
141+
negotiation.restart()
142+
negotiation.createOffer()
143+
}
144+
}
145+
146+
RoomController.values = { id: String, clientId: String }
147+
RoomController.targets = ['localMedium', 'remoteMedium', 'enter']

app/javascript/models/client.js

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
export default class Client {
2+
constructor (id) {
3+
this.callbacks = {}
4+
this.id = id
5+
}
6+
7+
get peerConnection () {
8+
return this.negotiation && this.negotiation.peerConnection
9+
}
10+
11+
streamTo (otherClient) {
12+
if (!otherClient.streaming) {
13+
this.stream.getTracks().forEach(track => {
14+
otherClient.peerConnection.addTrack(track, this.stream)
15+
})
16+
otherClient.streaming = true
17+
}
18+
}
19+
20+
on (name, callback) {
21+
const names = name.split(' ')
22+
names.forEach((name) => {
23+
this.callbacks[name] = this.callbacks[name] || []
24+
this.callbacks[name].push(callback)
25+
})
26+
}
27+
28+
broadcast (name, data) {
29+
(this.callbacks[name] || []).forEach(
30+
callback => callback.call(null, { type: name, detail: data })
31+
)
32+
}
33+
34+
off (name) {
35+
if (name) return delete this.callbacks[name]
36+
else this.callbacks = {}
37+
}
38+
39+
stop () {
40+
this.off()
41+
this.negotiation.stop()
42+
}
43+
}

0 commit comments

Comments
 (0)