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 +7 -0
- data/Gemfile +2 -0
- data/README.md +27 -0
- data/lib/generators/sbmeet/install_generator.rb +147 -0
- data/lib/generators/sbmeet/templates/channels/channel.rb +4 -0
- data/lib/generators/sbmeet/templates/channels/connection.rb +17 -0
- data/lib/generators/sbmeet/templates/channels/signaling_channel.rb +13 -0
- data/lib/generators/sbmeet/templates/config/cable.yml +9 -0
- data/lib/generators/sbmeet/templates/controllers/rooms_controller.rb +39 -0
- data/lib/generators/sbmeet/templates/javascript/channels/consumer.js +6 -0
- data/lib/generators/sbmeet/templates/javascript/channels/index.js +2 -0
- data/lib/generators/sbmeet/templates/javascript/channels/signaling_channel.js +15 -0
- data/lib/generators/sbmeet/templates/javascript/index.js +4 -0
- data/lib/generators/sbmeet/templates/javascript/room_controller.js +365 -0
- data/lib/generators/sbmeet/templates/migrations/create_rooms.rb.erb +8 -0
- data/lib/generators/sbmeet/templates/models/room.rb +3 -0
- data/lib/generators/sbmeet/templates/views/rooms/index.html.erb +63 -0
- data/lib/generators/sbmeet/templates/views/rooms/show.html.erb +73 -0
- data/lib/sbmeet/version.rb +3 -0
- data/lib/sbmeet.rb +5 -0
- data/sbmeet.gemspec +23 -0
- metadata +78 -0
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
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,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,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,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,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 →</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>
|
data/lib/sbmeet.rb
ADDED
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: []
|