sbmeet 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: ecf2a5562c629b32a4e74e341a52a9284398b2572c2b3f56af8d94fb2026d605
4
+ data.tar.gz: b816bfdc4728e1357db41c1d2e1bd84d6471426127f8da4c1ac58a2236ee693c
5
+ SHA512:
6
+ metadata.gz: a12fa26b5bb1facd24c5ffad5b9cec39d065312fe15a51853af082b9ee0970c9f0db8ca2d9dd9ac2165c486b3bc87054d5c248a264a6fcdf6ddcf73c816d24ff
7
+ data.tar.gz: b11fa95c46fc6c8f4811f506d3b5a3f4be94744d47699bd65a4eff111678ddbcae84b04a00c2622f1334c5e4435d4c9412be916991af635a65d752860853f368
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source "https://rubygems.org"
2
+ gemspec
data/README.md ADDED
@@ -0,0 +1,27 @@
1
+ # SBMeet
2
+
3
+ SBMeet is a fully functional, high-definition (1080p) WebRTC video conferencing for your rails application.
4
+
5
+ One on One only , fullscreen supported (even in stupid IOS),realtime bandwith and audio level visualisation.
6
+
7
+ It support 4/5g to wifi switch , page refresh and ios fullscreen and it comes with basic bootstrap styling.
8
+
9
+ Minimal implementation , with maximum performance .(Work on basic dyno of heroku)
10
+
11
+
12
+ ## Prerequisites
13
+ * **Devise** (Used to ensure room administration as well as reconection or participant maxing)
14
+ * **PostgreSQL** (Used natively for both ActiveRecord and ActionCable signaling)
15
+ * **Importmap, esbuild, or Webpack** (for Stimulus and ActionCable JS)
16
+ ## Installation
17
+ Add `gem 'sbmeet'` to your Gemfile and run `bundle install`.
18
+ It takes care of styling with bootstrap cdn if not present
19
+ It injects configurations and views files .
20
+
21
+ ## Usage
22
+ Run the installation generator:
23
+ `rails generate sbmeet:install`
24
+ `rails db:migrate`
25
+
26
+ Start your server (`bin/dev`) and navigate to `/rooms`.
27
+
@@ -0,0 +1,147 @@
1
+ require "rails/generators/active_record"
2
+
3
+ module Sbmeet
4
+ module Generators
5
+ class InstallGenerator < Rails::Generators::Base
6
+ include Rails::Generators::Migration
7
+ source_root File.expand_path("templates", __dir__)
8
+ desc "Injects the complete SBMeet WebRTC infrastructure into the host application."
9
+
10
+ def check_host_authentication_capabilities
11
+ say "Analyzing host application environment...", :yellow
12
+ Rails.application.eager_load!
13
+
14
+ user_model_path = "app/models/user.rb"
15
+
16
+ if ApplicationController.new.respond_to?(:current_user, true)
17
+ say_status :success, "Found 'current_user' definition.", :green
18
+
19
+ if ::File.exist?(user_model_path)
20
+ user_methods = defined?(User) ? User.instance_methods : []
21
+
22
+ if user_methods.include?(:admin?)
23
+ say_status :success, "Found 'admin?' method on the User model.", :green
24
+ else
25
+ say_status :warning, "User model found, but 'admin?' is missing.", :yellow
26
+ method_injection = <<~RUBY
27
+ # Added by SBMeet Installer
28
+ def admin?
29
+ Rails.env.development? || Rails.env.test?
30
+ end
31
+ RUBY
32
+
33
+ inject_into_file user_model_path, method_injection, after: /class User < ApplicationRecord.*\n/
34
+ say_status :insert, "Added fallback admin? method to #{user_model_path}", :green
35
+ end
36
+ else
37
+ say_status :error, "Could not find a standard 'User' model file at #{user_model_path}.", :red
38
+ exit 1
39
+ end
40
+ else
41
+ say_status :error, "No 'current_user' method detected in ApplicationController.", :red
42
+ say "Make sure you install and configure an authentication library (like Devise) before using SBMeet.", :white
43
+ exit 1
44
+ end
45
+ end
46
+
47
+ def check_if_pg
48
+ unless File.read("config/database.yml").include?("postgresql")
49
+ say_status :error, "SBMeet requires PostgreSQL for ActionCable signaling.", :red
50
+ exit 1
51
+ end
52
+ end
53
+
54
+ def verify_and_update_application_js
55
+ target_file = "app/javascript/application.js"
56
+
57
+ unless File.exist?(target_file)
58
+ say_status :error, "SBMeet requires a configured JavaScript asset pipeline.", :red
59
+ exit 1
60
+ end
61
+
62
+ js_content = File.read(target_file)
63
+
64
+ unless js_content.include?('import "channels"') || js_content.include?("import './channels'")
65
+ say_status :insert, "Appending channels import to application.js", :green
66
+ append_to_file target_file, "\nimport \"channels\""
67
+ end
68
+ end
69
+
70
+ def self.next_migration_number(dirname)
71
+ ActiveRecord::Generators::Base.next_migration_number(dirname)
72
+ end
73
+
74
+ def setup_database
75
+ migration_template "migrations/create_rooms.rb.erb", "db/migrate/create_rooms.rb"
76
+ say_status :success, "Created Room model migration.", :green
77
+ end
78
+
79
+ def verify_devise
80
+ has_devise_gem = File.read("Gemfile").include?("gem 'devise'")
81
+ has_devise_model = File.exist?("app/models/user.rb") && File.read("app/models/user.rb").include?("devise :")
82
+
83
+ if has_devise_gem && has_devise_model
84
+ say_status :success, "Devise authentication validated.", :green
85
+ else
86
+ say_status :warning, "Devise setup not fully detected. Check your configuration if connection errors occur.", :yellow
87
+ end
88
+ end
89
+
90
+ def copy_application_logic
91
+ copy_file "models/room.rb", "app/models/room.rb", force: true
92
+ copy_file "controllers/rooms_controller.rb", "app/controllers/rooms_controller.rb", force: true
93
+ end
94
+
95
+ def copy_frontend_assets
96
+ copy_file "javascript/room_controller.js", "app/javascript/controllers/room_controller.js", force: true
97
+ copy_file "javascript/index.js", "app/javascript/controllers/index.js", force: true
98
+
99
+ directory "javascript/channels", "app/javascript/channels", force: true
100
+ directory "views/rooms", "app/views/rooms", force: true
101
+ end
102
+
103
+ def inject_bootstrap_if_missing
104
+ has_bootstrap = ((File.exist?("config/importmap.rb") && File.read("config/importmap.rb").include?("bootstrap")) ||
105
+ (File.exist?("package.json") && File.read("package.json").include?("bootstrap")) ||
106
+ (File.exist?("app/assets/stylesheets/application.bootstrap.scss")))
107
+
108
+ unless has_bootstrap
109
+ say_status :info, "Injecting Bootstrap 5 CDN into room views.", :yellow
110
+ prepend_file "app/views/rooms/show.html.erb", "<link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css\" rel=\"stylesheet\">\n"
111
+ prepend_file "app/views/rooms/index.html.erb", "<link href=\"https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css\" rel=\"stylesheet\">\n"
112
+ end
113
+ end
114
+
115
+ def setup_action_cable
116
+ # Overwrite cable configuration with your PG/Redis required layout
117
+ template "config/cable.yml", "config/cable.yml", force: true
118
+
119
+ copy_file "channels/signaling_channel.rb", "app/channels/signaling_channel.rb", force: true
120
+
121
+ # FIXED: Use force: true to overwrite default empty Rails framework files
122
+ copy_file "channels/connection.rb", "app/channels/application_cable/connection.rb", force: true
123
+ copy_file "channels/channel.rb", "app/channels/application_cable/channel.rb", force: true
124
+ end
125
+
126
+ def setup_javascript_dependencies
127
+ if File.exist?("config/importmap.rb")
128
+ importmap_content = File.read("config/importmap.rb")
129
+
130
+ unless importmap_content.include?('"@rails/actioncable"')
131
+ append_to_file "config/importmap.rb", "\npin \"@rails/actioncable\", to: \"actioncable.esm.js\""
132
+ end
133
+ unless importmap_content.include?('pin_all_from "app/javascript/channels"')
134
+ append_to_file "config/importmap.rb", "\npin_all_from \"app/javascript/channels\", under: \"channels\""
135
+ end
136
+ unless importmap_content.include?('pin_all_from "app/javascript/controllers"')
137
+ append_to_file "config/importmap.rb", "\npin_all_from \"app/javascript/controllers\", under: \"controllers\""
138
+ end
139
+ end
140
+ end
141
+
142
+ def inject_routes
143
+ route "resources :rooms, only: [:index, :show, :new, :create, :destroy]"
144
+ end
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,4 @@
1
+ module ApplicationCable
2
+ class Channel < ActionCable::Channel::Base
3
+ end
4
+ end
@@ -0,0 +1,17 @@
1
+ module ApplicationCable
2
+ class Connection < ActionCable::Connection::Base
3
+ identified_by :current_user
4
+
5
+ def connect
6
+ self.current_user = find_verified_user
7
+ end
8
+
9
+ private
10
+
11
+ def find_verified_user
12
+ # Devise handles session tracking using warden inside cookies
13
+ env['warden'].user || reject_unauthorized_connection
14
+ end
15
+ end
16
+ end
17
+
@@ -0,0 +1,13 @@
1
+ class SignalingChannel < ApplicationCable::Channel
2
+ def subscribed
3
+ stream_from "signaling_room_#{params[:room_id]}"
4
+ end
5
+
6
+ def receive(data)
7
+ # Inject authenticated current_user context directly into outbound payloads
8
+ data[:user_id] = current_user.id
9
+ data[:user_name] = current_user.email.split('@').first.capitalize
10
+
11
+ ActionCable.server.broadcast("signaling_room_#{data['room_id']}", data)
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ development:
2
+ adapter: postgresql
3
+
4
+ test:
5
+ adapter: test
6
+
7
+ production:
8
+ adapter: postgresql
9
+ channel_prefix: sbmeet_production
@@ -0,0 +1,39 @@
1
+ class RoomsController < ApplicationController
2
+ before_action :authenticate_user!, only: [:index, :show, :create, :destroy]
3
+ def index
4
+ @rooms = Room.all
5
+ @room = Room.new
6
+ end
7
+
8
+ def show
9
+ @room = Room.find(params[:id])
10
+ @active_users = [@current_user]
11
+ end
12
+
13
+ def create
14
+ @room = Room.new(room_params)
15
+ if @room.save
16
+ redirect_to @room
17
+ else
18
+ render :index, status: :unprocessable_entity
19
+ end
20
+ end
21
+
22
+ def destroy
23
+ if current_user
24
+ @room = Room.find(params[:id])
25
+ @room.destroy
26
+ redirect_to rooms_path, notice: "Room deleted successfully."
27
+ else
28
+ redirect_to rooms_path, alert: "You do not have permission to delete this room."
29
+ end
30
+ end
31
+
32
+ private
33
+ def set_room
34
+ @room = Room.find(params[:id]) # This only requires params[:id], which the link sends.
35
+ end
36
+ def room_params
37
+ params.require(:room).permit(:name)
38
+ end
39
+ end
@@ -0,0 +1,6 @@
1
+ // Action Cable provides the framework to deal with WebSockets in Rails.
2
+ // You can generate new channels where WebSocket features live using the `bin/rails generate channel` command.
3
+
4
+ import { createConsumer } from "@rails/actioncable"
5
+
6
+ export default createConsumer()
@@ -0,0 +1,2 @@
1
+ // Import all the channels to be used by Action Cable
2
+ import "channels/signaling_channel"
@@ -0,0 +1,15 @@
1
+ import consumer from "channels/consumer"
2
+
3
+ consumer.subscriptions.create("SignalingChannel", {
4
+ connected() {
5
+ // Called when the subscription is ready for use on the server
6
+ },
7
+
8
+ disconnected() {
9
+ // Called when the subscription has been terminated by the server
10
+ },
11
+
12
+ received(data) {
13
+ // Called when there's incoming data on the websocket for this channel
14
+ }
15
+ });
@@ -0,0 +1,4 @@
1
+ // Import and register all your controllers from the importmap via controllers/**/*_controller
2
+ import { application } from "controllers/application"
3
+ import { eagerLoadControllersFrom } from "@hotwired/stimulus-loading"
4
+ eagerLoadControllersFrom("controllers", application)
@@ -0,0 +1,365 @@
1
+ // app/javascript/controllers/room_controller.js
2
+ import { Controller } from "@hotwired/stimulus"
3
+ import { createConsumer } from "@rails/actioncable"
4
+
5
+ const MEDIA_CONSTRAINTS = {
6
+ video: { width: { ideal: 1920 }, height: { ideal: 1080 }, frameRate: { ideal: 30, max: 60 }, facingMode: "user" },
7
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, channelCount: 1 }
8
+ }
9
+
10
+ const RTC_CONFIG = { iceServers: [{ urls: "stun:stun.l.google.com:19302" }] }
11
+ const MAX_BITRATE = 4000000
12
+
13
+ export default class extends Controller {
14
+ static targets = ["localVideo", "remoteContainer"]
15
+ static values = { id: Number, currentUserId: Number, currentUserName: String }
16
+
17
+ connect() {
18
+ this.peers = {}
19
+ this.audioAnimationFrames = {}
20
+ this.audioContext = null
21
+ this.consumer = createConsumer()
22
+ this.startLocalStream()
23
+ }
24
+
25
+ disconnect() {
26
+ if (this.signalingChannel) this.signalingChannel.unsubscribe()
27
+ if (this.localStream) this.localStream.getTracks().forEach(t => t.stop())
28
+ if (this.audioContext) this.audioContext.close()
29
+
30
+ Object.values(this.audioAnimationFrames).forEach(cancelAnimationFrame)
31
+ Object.values(this.peers).forEach(pc => clearInterval(pc.statsInterval))
32
+ }
33
+
34
+ // --- 1. MEDIA & SIGNALING INITIALIZATION ---
35
+
36
+ async startLocalStream() {
37
+ try {
38
+ this.localStream = await navigator.mediaDevices.getUserMedia(MEDIA_CONSTRAINTS)
39
+ this.localVideoTarget.srcObject = this.localStream
40
+ this.trackAudioLevel(this.localStream, "local-audio-level")
41
+ this.connectToSignaling()
42
+ } catch (error) {
43
+ console.error("Media permission rejected:", error)
44
+ alert("Microphone and Camera access are required.")
45
+ }
46
+ }
47
+
48
+ connectToSignaling() {
49
+ const self = this
50
+ this.signalingChannel = this.consumer.subscriptions.create(
51
+ { channel: "SignalingChannel", room_id: this.idValue },
52
+ {
53
+ connected() { self.transmit("join") },
54
+ received(data) { self.handleSignal(data) }
55
+ }
56
+ )
57
+ }
58
+
59
+ transmit(type, payload = {}) {
60
+ this.signalingChannel.perform("receive", { room_id: this.idValue, type, ...payload })
61
+ }
62
+
63
+ // --- 2. SIGNALING ROUTER ---
64
+
65
+ handleSignal(data) {
66
+ if (data.user_id === this.currentUserIdValue) return
67
+
68
+ if (data.type === "join") {
69
+ const separateActivePeers = Object.keys(this.peers).filter(id => parseInt(id) !== data.user_id)
70
+ if (separateActivePeers.length >= 1) {
71
+ return this.transmit("room-full", { target_user_id: data.user_id })
72
+ }
73
+ if (this.peers[data.user_id]) this.removeParticipant(data.user_id)
74
+ }
75
+
76
+ if (data.type === "room-full" && data.target_user_id === this.currentUserIdValue) {
77
+ return this.handleRoomFullExclusion()
78
+ }
79
+
80
+ if (["offer", "answer", "ice-candidate"].includes(data.type) && data.target_user_id !== this.currentUserIdValue) return
81
+
82
+ switch (data.type) {
83
+ case "join":
84
+ this.initPeer(data.user_id, data.user_name, true)
85
+ this.transmit("discover", { target_peer_id: data.user_id })
86
+ break
87
+ case "discover":
88
+ if (data.target_peer_id === this.currentUserIdValue) this.initPeer(data.user_id, data.user_name, false)
89
+ break
90
+ case "offer":
91
+ this.handleOffer(data.user_id, data.user_name, data.offer)
92
+ break
93
+ case "answer":
94
+ if (this.peers[data.user_id]) this.peers[data.user_id].setRemoteDescription(new RTCSessionDescription(data.answer))
95
+ break
96
+ case "ice-candidate":
97
+ if (this.peers[data.user_id]) this.peers[data.user_id].addIceCandidate(new RTCIceCandidate(data.candidate))
98
+ break
99
+ case "leave":
100
+ this.removeParticipant(data.user_id)
101
+ break
102
+ }
103
+ }
104
+
105
+ // --- 3. WEBRTC PIPELINE ---
106
+
107
+ initPeer(userId, userName, isOfferor) {
108
+ if (this.peers[userId]) return
109
+ this.addParticipantToRoster(userId, userName)
110
+
111
+ const pc = new RTCPeerConnection(RTC_CONFIG)
112
+ pc.isOfferor = isOfferor
113
+ this.peers[userId] = pc
114
+
115
+ this.localStream.getTracks().forEach(track => pc.addTrack(track, this.localStream))
116
+
117
+ pc.addEventListener("negotiationneeded", () => this.maximizeVideoBitrate(pc))
118
+
119
+ pc.oniceconnectionstatechange = () => {
120
+ if (["disconnected", "failed"].includes(pc.iceConnectionState)) {
121
+ this.triggerIceRestart(userId)
122
+ } else if (pc.iceConnectionState === "connected") {
123
+ this.trackNetworkStats(pc, userId)
124
+ }
125
+ }
126
+
127
+ pc.onicecandidate = (event) => {
128
+ if (event.candidate) this.transmit("ice-candidate", { candidate: event.candidate, target_user_id: userId })
129
+ }
130
+
131
+ pc.ontrack = (event) => this.mountRemoteVideo(userId, userName, event.streams[0])
132
+
133
+ if (isOfferor) this.createAndSendOffer(userId)
134
+ }
135
+
136
+ async maximizeVideoBitrate(pc) {
137
+ try {
138
+ const videoSender = pc.getSenders().find(s => s.track?.kind === "video")
139
+ if (!videoSender?.getParameters) return
140
+
141
+ const parameters = videoSender.getParameters()
142
+ if (!parameters.encodings) parameters.encodings = [{}]
143
+ parameters.encodings[0].maxBitrate = MAX_BITRATE
144
+ parameters.degradationPreference = "maintain-resolution"
145
+
146
+ await videoSender.setParameters(parameters)
147
+ } catch (err) {
148
+ console.warn("Bitrate override failed:", err)
149
+ }
150
+ }
151
+
152
+ async createAndSendOffer(userId, iceRestart = false) {
153
+ const pc = this.peers[userId]
154
+ const offer = await pc.createOffer({ iceRestart })
155
+ await pc.setLocalDescription(offer)
156
+ this.transmit("offer", { offer: pc.localDescription, target_user_id: userId })
157
+ }
158
+
159
+ async handleOffer(userId, userName, offer) {
160
+ this.initPeer(userId, userName, false)
161
+ const pc = this.peers[userId]
162
+ await pc.setRemoteDescription(new RTCSessionDescription(offer))
163
+ const answer = await pc.createAnswer()
164
+ await pc.setLocalDescription(answer)
165
+ this.transmit("answer", { answer: pc.localDescription, target_user_id: userId })
166
+ }
167
+
168
+ async triggerIceRestart(userId) {
169
+ if (this.peers[userId]?.isOfferor) {
170
+ console.log("Network topology shift. Initiating ICE restart...")
171
+ this.createAndSendOffer(userId, true)
172
+ }
173
+ }
174
+
175
+ // --- 4. NETWORK DIAGNOSTICS POLLING ---
176
+
177
+ trackNetworkStats(pc, userId) {
178
+ let lastResult = null
179
+
180
+ pc.statsInterval = setInterval(async () => {
181
+ if (pc.iceConnectionState !== "connected" && pc.iceConnectionState !== "completed") return
182
+
183
+ try {
184
+ const stats = await pc.getStats()
185
+
186
+ stats.forEach(report => {
187
+ if (report.type === "outbound-rtp" && report.kind === "video") {
188
+ const now = report.timestamp
189
+ const bytes = report.bytesSent
190
+
191
+ if (lastResult && lastResult.has(report.id)) {
192
+ const lastReport = lastResult.get(report.id)
193
+ const bitRate = (8 * (bytes - lastReport.bytesSent)) / (now - lastReport.timestamp)
194
+ const mbps = (bitRate / 1000).toFixed(2)
195
+
196
+ const localEmitLabel = document.getElementById("local-emit-rate")
197
+ if (localEmitLabel) localEmitLabel.innerText = mbps
198
+ }
199
+ }
200
+
201
+ if (report.type === "inbound-rtp" && report.kind === "video") {
202
+ const now = report.timestamp
203
+ const bytes = report.bytesReceived
204
+ const packetsLost = report.packetsLost
205
+
206
+ if (lastResult && lastResult.has(report.id)) {
207
+ const lastReport = lastResult.get(report.id)
208
+ const bitRate = (8 * (bytes - lastReport.bytesReceived)) / (now - lastReport.timestamp)
209
+ const mbps = (bitRate / 1000).toFixed(2)
210
+
211
+ const remoteRecvLabel = document.getElementById(`remote-recv-rate-${userId}`)
212
+ if (remoteRecvLabel) remoteRecvLabel.innerText = mbps
213
+
214
+ const remoteLossLabel = document.getElementById(`remote-loss-${userId}`)
215
+ if (remoteLossLabel) {
216
+ remoteLossLabel.innerText = packetsLost
217
+ const droppedRecently = packetsLost - lastReport.packetsLost
218
+ remoteLossLabel.className = droppedRecently > 0 ? "text-danger fw-bold" : "text-success"
219
+ }
220
+ }
221
+ }
222
+ })
223
+
224
+ lastResult = stats
225
+ } catch (err) {
226
+ console.warn("Could not retrieve WebRTC stats:", err)
227
+ }
228
+ }, 1000)
229
+ }
230
+
231
+ // --- 5. DOM & UI MANAGEMENT ---
232
+
233
+ mountRemoteVideo(userId, userName, stream) {
234
+ if (document.getElementById(`video-${userId}`)) return
235
+
236
+ const html = `
237
+ <div class="col-12" id="container-${userId}">
238
+ <div class="card bg-dark border-0 shadow overflow-hidden">
239
+ <div class="position-relative d-flex flex-column bg-black">
240
+ <div class="d-flex position-relative" id="wrapper-${userId}">
241
+ <div class="ratio ratio-16x9 bg-black flex-grow-1">
242
+ <video id="video-${userId}" autoplay playsinline class="w-100 h-100 object-fit-cover"></video>
243
+ </div>
244
+ <div class="bg-black border-start border-secondary p-1 d-flex align-items-end" style="width: 14px;">
245
+ <div id="audio-level-${userId}" class="w-100 bg-success rounded-top" style="height: 0%; min-height: 2px; transition: height 0.05s ease;"></div>
246
+ </div>
247
+ <span class="position-absolute bottom-0 start-0 m-3 badge bg-secondary opacity-75 z-3">${userName}</span>
248
+ <button id="fullscreen-${userId}" class="btn btn-sm btn-light position-absolute top-0 end-0 m-2 opacity-50 hover-opacity-100 z-3" title="Toggle Fullscreen">⛶</button>
249
+ </div>
250
+
251
+ <div class="bg-dark text-white p-2 small font-monospace d-flex justify-content-between border-top border-secondary" style="font-size: 0.75rem;">
252
+ <div>📥 Receiving: <span id="remote-recv-rate-${userId}">0.00</span> Mbps</div>
253
+ <div>📉 Loss: <span id="remote-loss-${userId}" class="text-success">0</span> pkts</div>
254
+ </div>
255
+ </div>
256
+ </div>
257
+ </div>
258
+ `
259
+ this.remoteContainerTarget.insertAdjacentHTML('beforeend', html)
260
+
261
+ const videoEl = document.getElementById(`video-${userId}`)
262
+ videoEl.srcObject = stream
263
+
264
+ document.getElementById(`fullscreen-${userId}`).onclick = () => this.toggleFullscreen(document.getElementById(`wrapper-${userId}`), videoEl)
265
+
266
+ this.trackAudioLevel(stream, `audio-level-${userId}`, userId)
267
+ }
268
+
269
+ toggleFullscreen(containerNode, videoNode) {
270
+ if (typeof videoNode.webkitEnterFullscreen === 'function') {
271
+ return videoNode.webkitEnterFullscreen()
272
+ }
273
+
274
+ if (!document.fullscreenElement) {
275
+ containerNode.requestFullscreen().catch(() => {
276
+ if (videoNode.requestFullscreen) videoNode.requestFullscreen().catch(console.error)
277
+ })
278
+ } else {
279
+ if (document.exitFullscreen) document.exitFullscreen()
280
+ }
281
+ }
282
+
283
+ toggleLocalFullscreen() {
284
+ this.toggleFullscreen(this.localVideoTarget.closest(".d-flex.position-relative"), this.localVideoTarget)
285
+ }
286
+
287
+ handleRoomFullExclusion() {
288
+ alert("This room is full. Only 2 participants are allowed simultaneously.")
289
+ this.disconnect()
290
+ window.location.href = "/"
291
+ }
292
+
293
+ // --- 6. AUDIO VISUALIZER ---
294
+
295
+ trackAudioLevel(stream, elementId, userId = 'local') {
296
+ if (stream.getAudioTracks().length === 0) return
297
+
298
+ try {
299
+ if (!this.audioContext) this.audioContext = new (window.AudioContext || window.webkitAudioContext)()
300
+
301
+ const analyser = this.audioContext.createAnalyser()
302
+ const source = this.audioContext.createMediaStreamSource(stream)
303
+
304
+ analyser.fftSize = 256
305
+ source.connect(analyser)
306
+
307
+ const dataArray = new Uint8Array(analyser.frequencyBinCount)
308
+
309
+ const updateLevel = () => {
310
+ const progressBar = document.getElementById(elementId)
311
+ if (!progressBar) return
312
+
313
+ analyser.getByteFrequencyData(dataArray)
314
+ const average = dataArray.reduce((acc, val) => acc + val, 0) / analyser.frequencyBinCount
315
+ const percentage = Math.min(Math.round((average / 120) * 100), 100)
316
+
317
+ progressBar.style.height = `${percentage}%`
318
+ progressBar.className = `w-100 rounded-top ${percentage > 80 ? 'bg-danger' : percentage > 45 ? 'bg-warning' : 'bg-success'}`
319
+
320
+ this.audioAnimationFrames[userId] = requestAnimationFrame(updateLevel)
321
+ }
322
+
323
+ updateLevel()
324
+ } catch (e) {
325
+ console.error("Audio context initialization failure", e)
326
+ }
327
+ }
328
+
329
+ // --- 7. ROSTER MANAGEMENT ---
330
+
331
+ addParticipantToRoster(userId, userName) {
332
+ if (document.getElementById(`roster-${userId}`)) return
333
+ const listContainer = document.getElementById("participant-list")
334
+ if (!listContainer) return
335
+
336
+ listContainer.insertAdjacentHTML('beforeend', `
337
+ <li id="roster-${userId}" class="list-group-item d-flex align-items-center text-muted">
338
+ <span class="p-1 bg-secondary border border-light rounded-circle me-2"></span>${userName}
339
+ </li>
340
+ `)
341
+ this.updateParticipantCount()
342
+ }
343
+
344
+ removeParticipant(userId) {
345
+ if (this.peers[userId]) {
346
+ clearInterval(this.peers[userId].statsInterval)
347
+ this.peers[userId].close()
348
+ delete this.peers[userId]
349
+ }
350
+
351
+ if (this.audioAnimationFrames[userId]) {
352
+ cancelAnimationFrame(this.audioAnimationFrames[userId])
353
+ delete this.audioAnimationFrames[userId]
354
+ }
355
+
356
+ document.getElementById(`container-${userId}`)?.remove()
357
+ document.getElementById(`roster-${userId}`)?.remove()
358
+ this.updateParticipantCount()
359
+ }
360
+
361
+ updateParticipantCount() {
362
+ const counter = document.getElementById("participant-count")
363
+ if (counter) counter.innerText = Object.keys(this.peers).length + 1
364
+ }
365
+ }
@@ -0,0 +1,8 @@
1
+ class CreateRooms < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
2
+ def change
3
+ create_table :rooms do |t|
4
+ t.string :name, null: false
5
+ t.timestamps
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,3 @@
1
+ class Room < ApplicationRecord
2
+ validates :name, presence: true
3
+ end
@@ -0,0 +1,63 @@
1
+ <div class="container my-5">
2
+ <div class="row justify-content-center">
3
+ <div class="col-md-8">
4
+ <div class="d-flex justify-content-between align-items-center mb-5">
5
+ <div>
6
+ <h1 class="display-5 fw-bold text-dark">Video Rooms</h1>
7
+ <p class="text-muted">Select an existing workspace or create a new video conference instantly.</p>
8
+ </div>
9
+ </div>
10
+ <div class="card border-0 shadow-sm mb-5 bg-light">
11
+ <div class="card-body p-4">
12
+ <h2 class="h5 mb-3 fw-semibold">Create a New Room</h2>
13
+ <%= form_with(model: @room, class: "row g-3 align-items-center") do |f| %>
14
+ <% if @room.errors.any? %>
15
+ <div class="col-12">
16
+ <div class="alert alert-danger py-2 px-3 small mb-0" role="alert">
17
+ <%= @room.errors.full_messages.to_sentence %>
18
+ </div>
19
+ </div>
20
+ <% end %>
21
+ <div class="col-sm-8">
22
+ <%= f.text_field :name,
23
+ placeholder: "e.g., Standup Meeting, Coding Sync",
24
+ class: "form-control form-control-lg border-secondary-subtle",
25
+ required: true %>
26
+ </div>
27
+ <div class="col-sm-4 d-grid">
28
+ <%= f.submit "Launch Room", class: "btn btn-primary btn-lg fw-medium" %>
29
+ </div>
30
+ <% end %>
31
+ </div>
32
+ </div>
33
+ <h2 class="h4 mb-3 fw-bold text-secondary">Active Sessions</h2>
34
+ <% if @rooms.any? %>
35
+ <div class="list-group shadow-sm rounded-3">
36
+ <% @rooms.each do |room| %>
37
+ <%= link_to room_path(room), class: "list-group-item list-group-item-action d-flex justify-content-between align-items-center p-3 transition" do %>
38
+ <div>
39
+ <span class="fw-semibold text-dark text-lg"><%= room.name %></span>
40
+ <div class="small text-muted">ID: #<%= room.id %></div>
41
+ </div>
42
+ <span class="btn btn-sm btn-outline-primary px-3 rounded-pill">Join Room &rarr;</span>
43
+ <% end %>
44
+ <%if current_user && current_user.admin?%>
45
+ <%= link_to "Destroy Room", room_path(room),
46
+ data: { turbo_method: :delete, turbo_confirm: "Are you sure you want to delete this room?" },
47
+ class: "btn btn-outline-danger" %>
48
+ <%end%>
49
+ <% end %>
50
+ </div>
51
+ <% else %>
52
+ <div class="text-center p-5 border border-dashed rounded-3 bg-white">
53
+ <div class="text-muted mb-3">
54
+ <svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" class="bi bi-camera-video text-secondary opacity-50" viewBox="0 0 16 16">
55
+ <path fill-rule="evenodd" d="M0 5a2 2 0 0 1 2-2h7.5a2 2 0 0 1 1.983 1.738l3.11-1.382A1 1 0 0 1 16 4.269v7.462a1 1 0 0 1-1.406.913l-3.111-1.382A2 2 0 0 1 9.5 13H2a2 2 0 0 1-2-2zm11.5 5.175 3.5 1.556V4.269l-3.5 1.556zM2 4a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h7.5a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1z"/>
56
+ </svg>
57
+ </div>
58
+ <p class="text-muted mb-0">No active video conference rooms found. Create one above to get started.</p>
59
+ </div>
60
+ <% end %>
61
+ </div>
62
+ </div>
63
+ </div>
@@ -0,0 +1,73 @@
1
+ <style>
2
+ .hover-opacity-100:hover { opacity: 1 !important; }
3
+ </style>
4
+
5
+ <div class="container my-5"
6
+ data-controller="room"
7
+ data-room-id-value="<%= @room.id %>"
8
+ data-room-current-user-id-value="<%= current_user.id %>"
9
+ data-room-current-user-name-value="<%= current_user.email.split('@').first.capitalize %>">
10
+
11
+ <div class="d-flex justify-content-between align-items-center mb-4">
12
+ <div><h1 class="h2 mb-1">Room: <%= @room.name %></h1></div>
13
+ <%= link_to 'Leave Room', rooms_path, class: 'btn btn-outline-danger fw-medium' %>
14
+ </div>
15
+
16
+ <div class="row g-4">
17
+ <div class="col-12 col-md-9">
18
+ <div class="row g-3">
19
+
20
+ <%# Local Video Frame %>
21
+ <div class="col-12 col-md-6" id="local-video-container">
22
+ <div class="card bg-dark border-0 shadow overflow-hidden">
23
+ <div class="position-relative d-flex flex-column bg-black">
24
+ <div class="d-flex position-relative">
25
+ <div class="ratio ratio-16x9 bg-black flex-grow-1">
26
+ <video data-room-target="localVideo" autoplay playsinline muted class="w-100 h-100 object-fit-cover"></video>
27
+ </div>
28
+ <div class="bg-black border-start border-secondary p-1 d-flex align-items-end" style="width: 14px;">
29
+ <div id="local-audio-level" class="w-100 bg-success rounded-top" style="height: 0%; min-height: 2px; transition: height 0.05s ease;"></div>
30
+ </div>
31
+ <span class="position-absolute bottom-0 start-0 m-3 badge bg-secondary opacity-75 z-3">
32
+ You (<%= current_user.email.split('@').first.capitalize %>)
33
+ </span>
34
+ <button data-action="click->room#toggleLocalFullscreen" class="btn btn-sm btn-light position-absolute top-0 end-0 m-2 opacity-50 hover-opacity-100 z-3" title="Toggle Fullscreen">⛶</button>
35
+ </div>
36
+
37
+ <%# LOCAL LIVE STATS FOOTER %>
38
+ <div class="bg-dark text-white p-2 small font-monospace d-flex justify-content-between border-top border-secondary" style="font-size: 0.75rem;">
39
+ <div>🚀 Emitting: <span id="local-emit-rate">0.00</span> Mbps</div>
40
+ <div class="text-success">● Local Camera</div>
41
+ </div>
42
+
43
+ </div>
44
+ </div>
45
+ </div>
46
+
47
+ <%# Remote Video Container %>
48
+ <div class="col-12 col-md-6">
49
+ <div id="remote-videos" data-room-target="remoteContainer" class="row g-3"></div>
50
+ </div>
51
+
52
+ </div>
53
+ </div>
54
+
55
+ <%# Sidebar: Active Users %>
56
+ <div class="col-12 col-md-3">
57
+ <div class="card border-0 shadow-sm h-100">
58
+ <div class="card-header bg-white border-0 pt-3">
59
+ <h5 class="card-title fw-bold text-secondary mb-0">Active Users</h5>
60
+ </div>
61
+ <div class="card-body">
62
+ <ul class="list-group list-group-flush small" id="participant-list">
63
+ <li class="list-group-item d-flex align-items-center text-success fw-semibold" id="list-item-local">
64
+ <span class="p-1 bg-success border border-light rounded-circle me-2"></span>
65
+ <%= current_user.email.split('@').first.capitalize %> (You)
66
+ </li>
67
+ </ul>
68
+ </div>
69
+ </div>
70
+ </div>
71
+
72
+ </div>
73
+ </div>
@@ -0,0 +1,3 @@
1
+ module Sbmeet
2
+ VERSION = "0.1.0"
3
+ end
data/lib/sbmeet.rb ADDED
@@ -0,0 +1,5 @@
1
+ require_relative "sbmeet/version"
2
+
3
+ module Sbmeet
4
+ class Error < StandardError; end
5
+ end
data/sbmeet.gemspec ADDED
@@ -0,0 +1,23 @@
1
+ require_relative "lib/sbmeet/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "sbmeet"
5
+ spec.version = Sbmeet::VERSION
6
+ spec.authors = ["Stéphane Ballet"]
7
+ spec.email = ["plombix@gmail.com"]
8
+ spec.summary = "A turnkey WebRTC video conferencing for Rails."
9
+ spec.description = "SBMeet injects a complete, production-ready P2P WebRTC video conferencing system directly into a Rails application."
10
+ spec.homepage = "https://github.com/yourusername/sbmeet"
11
+ spec.license = "MIT"
12
+ spec.required_ruby_version = ">= 3.0.0"
13
+
14
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
15
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:(?:test|spec|features)/|\.(?:git|travis|circleci)|appveyor)}) }
16
+ end
17
+
18
+ spec.bindir = "exe"
19
+ spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
20
+ spec.require_paths = ["lib"]
21
+
22
+ spec.add_dependency "rails", ">= 7.0.0"
23
+ end
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: sbmeet
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Stéphane Ballet
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-06-30 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: rails
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: 7.0.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: 7.0.0
27
+ description: SBMeet injects a complete, production-ready P2P WebRTC video conferencing
28
+ system directly into a Rails application.
29
+ email:
30
+ - plombix@gmail.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - Gemfile
36
+ - README.md
37
+ - lib/generators/sbmeet/install_generator.rb
38
+ - lib/generators/sbmeet/templates/channels/channel.rb
39
+ - lib/generators/sbmeet/templates/channels/connection.rb
40
+ - lib/generators/sbmeet/templates/channels/signaling_channel.rb
41
+ - lib/generators/sbmeet/templates/config/cable.yml
42
+ - lib/generators/sbmeet/templates/controllers/rooms_controller.rb
43
+ - lib/generators/sbmeet/templates/javascript/channels/consumer.js
44
+ - lib/generators/sbmeet/templates/javascript/channels/index.js
45
+ - lib/generators/sbmeet/templates/javascript/channels/signaling_channel.js
46
+ - lib/generators/sbmeet/templates/javascript/index.js
47
+ - lib/generators/sbmeet/templates/javascript/room_controller.js
48
+ - lib/generators/sbmeet/templates/migrations/create_rooms.rb.erb
49
+ - lib/generators/sbmeet/templates/models/room.rb
50
+ - lib/generators/sbmeet/templates/views/rooms/index.html.erb
51
+ - lib/generators/sbmeet/templates/views/rooms/show.html.erb
52
+ - lib/sbmeet.rb
53
+ - lib/sbmeet/version.rb
54
+ - sbmeet.gemspec
55
+ homepage: https://github.com/yourusername/sbmeet
56
+ licenses:
57
+ - MIT
58
+ metadata: {}
59
+ post_install_message:
60
+ rdoc_options: []
61
+ require_paths:
62
+ - lib
63
+ required_ruby_version: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: 3.0.0
68
+ required_rubygems_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: '0'
73
+ requirements: []
74
+ rubygems_version: 3.4.1
75
+ signing_key:
76
+ specification_version: 4
77
+ summary: A turnkey WebRTC video conferencing for Rails.
78
+ test_files: []