Add webrtc chat
This commit is contained in:
parent
43a71faddf
commit
929c98bb14
2
Gemfile
2
Gemfile
@ -3,7 +3,7 @@ ruby '2.7.7'
|
|||||||
gem 'rails', '~> 6.0'
|
gem 'rails', '~> 6.0'
|
||||||
# TODO Upgrade sprockets
|
# TODO Upgrade sprockets
|
||||||
gem 'redis', '~> 4.0'
|
gem 'redis', '~> 4.0'
|
||||||
gem 'sprockets', '~> 3.7'
|
gem 'sprockets' #, '~> 3.7'
|
||||||
gem 'sass-rails' #, '~> 4.0.3'
|
gem 'sass-rails' #, '~> 4.0.3'
|
||||||
gem 'uglifier' #, '>= 1.3.0'
|
gem 'uglifier' #, '>= 1.3.0'
|
||||||
gem 'coffee-rails' #, '~> 4.0.0'
|
gem 'coffee-rails' #, '~> 4.0.0'
|
||||||
|
|||||||
240
Gemfile.lock
240
Gemfile.lock
@ -67,37 +67,38 @@ GEM
|
|||||||
minitest (>= 5.1)
|
minitest (>= 5.1)
|
||||||
tzinfo (~> 2.0)
|
tzinfo (~> 2.0)
|
||||||
zeitwerk (~> 2.3)
|
zeitwerk (~> 2.3)
|
||||||
addressable (2.8.0)
|
addressable (2.8.5)
|
||||||
public_suffix (>= 2.0.2, < 5.0)
|
public_suffix (>= 2.0.2, < 6.0)
|
||||||
airbrussh (1.4.0)
|
airbrussh (1.5.0)
|
||||||
sshkit (>= 1.6.1, != 1.7.0)
|
sshkit (>= 1.6.1, != 1.7.0)
|
||||||
ast (2.4.2)
|
ast (2.4.2)
|
||||||
babel-source (5.8.35)
|
babel-source (5.8.35)
|
||||||
babel-transpiler (0.7.0)
|
babel-transpiler (0.7.0)
|
||||||
babel-source (>= 4.0, < 6)
|
babel-source (>= 4.0, < 6)
|
||||||
execjs (~> 2.0)
|
execjs (~> 2.0)
|
||||||
bcrypt (3.1.16)
|
base64 (0.1.1)
|
||||||
|
bcrypt (3.1.19)
|
||||||
bcrypt_pbkdf (1.1.0)
|
bcrypt_pbkdf (1.1.0)
|
||||||
better_errors (2.9.1)
|
better_errors (2.10.1)
|
||||||
coderay (>= 1.0.0)
|
|
||||||
erubi (>= 1.0.0)
|
erubi (>= 1.0.0)
|
||||||
rack (>= 0.9.0)
|
rack (>= 0.9.0)
|
||||||
|
rouge (>= 1.0.0)
|
||||||
binding_of_caller (1.0.0)
|
binding_of_caller (1.0.0)
|
||||||
debug_inspector (>= 0.0.1)
|
debug_inspector (>= 0.0.1)
|
||||||
builder (3.2.4)
|
builder (3.2.4)
|
||||||
capistrano (3.16.0)
|
capistrano (3.18.0)
|
||||||
airbrussh (>= 1.0.0)
|
airbrussh (>= 1.0.0)
|
||||||
i18n
|
i18n
|
||||||
rake (>= 10.0.0)
|
rake (>= 10.0.0)
|
||||||
sshkit (>= 1.9.0)
|
sshkit (>= 1.9.0)
|
||||||
capistrano-bundler (2.0.1)
|
capistrano-bundler (2.1.0)
|
||||||
capistrano (~> 3.1)
|
capistrano (~> 3.1)
|
||||||
capistrano-db-tasks (0.6)
|
capistrano-db-tasks (0.6)
|
||||||
capistrano (>= 3.0.0)
|
capistrano (>= 3.0.0)
|
||||||
capistrano-postgresql (6.2.0)
|
capistrano-postgresql (6.2.0)
|
||||||
capistrano (>= 3.0)
|
capistrano (>= 3.0)
|
||||||
sshkit (>= 1.17.0)
|
sshkit (>= 1.17.0)
|
||||||
capistrano-rails (1.6.1)
|
capistrano-rails (1.6.3)
|
||||||
capistrano (~> 3.1)
|
capistrano (~> 3.1)
|
||||||
capistrano-bundler (>= 1.1, < 3)
|
capistrano-bundler (>= 1.1, < 3)
|
||||||
capistrano-rails-console (2.3.0)
|
capistrano-rails-console (2.3.0)
|
||||||
@ -110,7 +111,7 @@ GEM
|
|||||||
capistrano (~> 3.7)
|
capistrano (~> 3.7)
|
||||||
capistrano-bundler
|
capistrano-bundler
|
||||||
puma (~> 3.4)
|
puma (~> 3.4)
|
||||||
capybara (3.36.0)
|
capybara (3.39.2)
|
||||||
addressable
|
addressable
|
||||||
matrix
|
matrix
|
||||||
mini_mime (>= 0.1.3)
|
mini_mime (>= 0.1.3)
|
||||||
@ -119,12 +120,12 @@ GEM
|
|||||||
rack-test (>= 0.6.3)
|
rack-test (>= 0.6.3)
|
||||||
regexp_parser (>= 1.5, < 3.0)
|
regexp_parser (>= 1.5, < 3.0)
|
||||||
xpath (~> 3.2)
|
xpath (~> 3.2)
|
||||||
capybara-screenshot (1.0.25)
|
capybara-screenshot (1.0.26)
|
||||||
capybara (>= 1.0, < 4)
|
capybara (>= 1.0, < 4)
|
||||||
launchy
|
launchy
|
||||||
childprocess (4.1.0)
|
ci_reporter (2.1.0)
|
||||||
ci_reporter (2.0.0)
|
|
||||||
builder (>= 2.1.2)
|
builder (>= 2.1.2)
|
||||||
|
rexml
|
||||||
ci_reporter_rspec (1.0.0)
|
ci_reporter_rspec (1.0.0)
|
||||||
ci_reporter (~> 2.0)
|
ci_reporter (~> 2.0)
|
||||||
rspec (>= 2.14, < 4)
|
rspec (>= 2.14, < 4)
|
||||||
@ -137,30 +138,30 @@ GEM
|
|||||||
execjs
|
execjs
|
||||||
coffee-script-source (1.12.2)
|
coffee-script-source (1.12.2)
|
||||||
concurrent-ruby (1.2.2)
|
concurrent-ruby (1.2.2)
|
||||||
connection_pool (2.2.5)
|
connection_pool (2.4.1)
|
||||||
crass (1.0.6)
|
crass (1.0.6)
|
||||||
database_cleaner (2.0.1)
|
database_cleaner (2.0.2)
|
||||||
database_cleaner-active_record (~> 2.0.0)
|
database_cleaner-active_record (>= 2, < 3)
|
||||||
database_cleaner-active_record (2.0.1)
|
database_cleaner-active_record (2.1.0)
|
||||||
activerecord (>= 5.a)
|
activerecord (>= 5.a)
|
||||||
database_cleaner-core (~> 2.0.0)
|
database_cleaner-core (~> 2.0.0)
|
||||||
database_cleaner-core (2.0.1)
|
database_cleaner-core (2.0.1)
|
||||||
date (3.3.3)
|
date (3.3.3)
|
||||||
debug_inspector (1.1.0)
|
debug_inspector (1.1.0)
|
||||||
devise (4.8.0)
|
devise (4.9.3)
|
||||||
bcrypt (~> 3.0)
|
bcrypt (~> 3.0)
|
||||||
orm_adapter (~> 0.1)
|
orm_adapter (~> 0.1)
|
||||||
railties (>= 4.1.0)
|
railties (>= 4.1.0)
|
||||||
responders
|
responders
|
||||||
warden (~> 1.2.3)
|
warden (~> 1.2.3)
|
||||||
devise_invitable (2.0.5)
|
devise_invitable (2.0.8)
|
||||||
actionmailer (>= 5.0)
|
actionmailer (>= 5.0)
|
||||||
devise (>= 4.6)
|
devise (>= 4.6)
|
||||||
diff-lcs (1.4.4)
|
diff-lcs (1.5.0)
|
||||||
docile (1.4.0)
|
docile (1.4.0)
|
||||||
dotenv (2.7.6)
|
dotenv (2.8.1)
|
||||||
dotenv-rails (2.7.6)
|
dotenv-rails (2.8.1)
|
||||||
dotenv (= 2.7.6)
|
dotenv (= 2.8.1)
|
||||||
railties (>= 3.2)
|
railties (>= 3.2)
|
||||||
ed25519 (1.3.0)
|
ed25519 (1.3.0)
|
||||||
em-websocket (0.5.3)
|
em-websocket (0.5.3)
|
||||||
@ -169,21 +170,21 @@ GEM
|
|||||||
erubi (1.12.0)
|
erubi (1.12.0)
|
||||||
erubis (2.7.0)
|
erubis (2.7.0)
|
||||||
eventmachine (1.2.7)
|
eventmachine (1.2.7)
|
||||||
execjs (2.8.1)
|
execjs (2.9.1)
|
||||||
factory_girl (4.9.0)
|
factory_girl (4.9.0)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
factory_girl_rails (4.9.0)
|
factory_girl_rails (4.9.0)
|
||||||
factory_girl (~> 4.9.0)
|
factory_girl (~> 4.9.0)
|
||||||
railties (>= 3.0.0)
|
railties (>= 3.0.0)
|
||||||
faker (2.19.0)
|
faker (3.2.1)
|
||||||
i18n (>= 1.6, < 2)
|
i18n (>= 1.8.11, < 2)
|
||||||
ffi (1.15.4)
|
ffi (1.16.3)
|
||||||
formatador (0.3.0)
|
formatador (1.1.0)
|
||||||
friendly_id (5.4.2)
|
friendly_id (5.4.2)
|
||||||
activerecord (>= 4.0.0)
|
activerecord (>= 4.0.0)
|
||||||
globalid (1.2.1)
|
globalid (1.2.1)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
guard (2.18.0)
|
guard (2.18.1)
|
||||||
formatador (>= 0.2.4)
|
formatador (>= 0.2.4)
|
||||||
listen (>= 2.7, < 4.0)
|
listen (>= 2.7, < 4.0)
|
||||||
lumberjack (>= 1.0.12, < 2.0)
|
lumberjack (>= 1.0.12, < 2.0)
|
||||||
@ -192,8 +193,8 @@ GEM
|
|||||||
pry (>= 0.13.0)
|
pry (>= 0.13.0)
|
||||||
shellany (~> 0.0)
|
shellany (~> 0.0)
|
||||||
thor (>= 0.18.1)
|
thor (>= 0.18.1)
|
||||||
guard-bundler (2.2.1)
|
guard-bundler (3.0.1)
|
||||||
bundler (>= 1.3.0, < 3)
|
bundler (>= 2.1, < 3)
|
||||||
guard (~> 2.2)
|
guard (~> 2.2)
|
||||||
guard-compat (~> 1.1)
|
guard-compat (~> 1.1)
|
||||||
guard-compat (1.2.1)
|
guard-compat (1.2.1)
|
||||||
@ -212,19 +213,19 @@ GEM
|
|||||||
guard (~> 2.1)
|
guard (~> 2.1)
|
||||||
guard-compat (~> 1.1)
|
guard-compat (~> 1.1)
|
||||||
rspec (>= 2.99.0, < 4.0)
|
rspec (>= 2.99.0, < 4.0)
|
||||||
haml (5.2.2)
|
haml (6.2.3)
|
||||||
temple (>= 0.8.0)
|
temple (>= 0.8.2)
|
||||||
|
thor
|
||||||
tilt
|
tilt
|
||||||
haml-rails (2.0.1)
|
haml-rails (2.1.0)
|
||||||
actionpack (>= 5.1)
|
actionpack (>= 5.1)
|
||||||
activesupport (>= 5.1)
|
activesupport (>= 5.1)
|
||||||
haml (>= 4.0.6, < 6.0)
|
haml (>= 4.0.6)
|
||||||
html2haml (>= 1.0.1)
|
|
||||||
railties (>= 5.1)
|
railties (>= 5.1)
|
||||||
high_voltage (3.1.2)
|
high_voltage (3.1.2)
|
||||||
html2haml (2.2.0)
|
html2haml (2.3.0)
|
||||||
erubis (~> 2.7.0)
|
erubis (~> 2.7.0)
|
||||||
haml (>= 4.0, < 6)
|
haml (>= 4.0)
|
||||||
nokogiri (>= 1.6.0)
|
nokogiri (>= 1.6.0)
|
||||||
ruby_parser (~> 3.5)
|
ruby_parser (~> 3.5)
|
||||||
http_parser.rb (0.8.0)
|
http_parser.rb (0.8.0)
|
||||||
@ -238,23 +239,26 @@ GEM
|
|||||||
activesupport (>= 3.0)
|
activesupport (>= 3.0)
|
||||||
nokogiri (>= 1.6)
|
nokogiri (>= 1.6)
|
||||||
interception (0.5)
|
interception (0.5)
|
||||||
jbuilder (2.11.2)
|
jbuilder (2.11.5)
|
||||||
|
actionview (>= 5.0.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
jquery-rails (4.4.0)
|
jquery-rails (4.6.0)
|
||||||
rails-dom-testing (>= 1, < 3)
|
rails-dom-testing (>= 1, < 3)
|
||||||
railties (>= 4.2.0)
|
railties (>= 4.2.0)
|
||||||
thor (>= 0.14, < 2.0)
|
thor (>= 0.14, < 2.0)
|
||||||
jquery-ui-rails (6.0.1)
|
jquery-ui-rails (6.0.1)
|
||||||
railties (>= 3.2.16)
|
railties (>= 3.2.16)
|
||||||
launchy (2.5.0)
|
json (2.6.3)
|
||||||
addressable (~> 2.7)
|
language_server-protocol (3.17.0.3)
|
||||||
listen (3.7.0)
|
launchy (2.5.2)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
listen (3.8.0)
|
||||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||||
rb-inotify (~> 0.9, >= 0.9.10)
|
rb-inotify (~> 0.9, >= 0.9.10)
|
||||||
loofah (2.21.4)
|
loofah (2.21.4)
|
||||||
crass (~> 1.0.2)
|
crass (~> 1.0.2)
|
||||||
nokogiri (>= 1.12.0)
|
nokogiri (>= 1.12.0)
|
||||||
lumberjack (1.2.8)
|
lumberjack (1.2.9)
|
||||||
mail (2.8.1)
|
mail (2.8.1)
|
||||||
mini_mime (>= 0.1.1)
|
mini_mime (>= 0.1.1)
|
||||||
net-imap
|
net-imap
|
||||||
@ -264,22 +268,22 @@ GEM
|
|||||||
matrix (0.4.2)
|
matrix (0.4.2)
|
||||||
method_source (1.0.0)
|
method_source (1.0.0)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
mini_portile2 (2.8.4)
|
mini_portile2 (2.8.5)
|
||||||
minitest (5.20.0)
|
minitest (5.20.0)
|
||||||
multi_json (1.15.0)
|
multi_json (1.15.0)
|
||||||
nenv (0.3.0)
|
nenv (0.3.0)
|
||||||
net-imap (0.4.1)
|
net-imap (0.4.2)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
net-protocol
|
net-protocol
|
||||||
net-protocol (0.2.1)
|
net-protocol (0.2.1)
|
||||||
timeout
|
timeout
|
||||||
net-scp (3.0.0)
|
net-scp (4.0.0)
|
||||||
net-ssh (>= 2.6.5, < 7.0.0)
|
net-ssh (>= 2.6.5, < 8.0.0)
|
||||||
net-smtp (0.4.0)
|
net-smtp (0.4.0)
|
||||||
net-protocol
|
net-protocol
|
||||||
net-ssh (6.1.0)
|
net-ssh (7.2.0)
|
||||||
nio4r (2.5.9)
|
nio4r (2.5.9)
|
||||||
nokogiri (1.15.4)
|
nokogiri (1.15.4)
|
||||||
mini_portile2 (~> 2.8.2)
|
mini_portile2 (~> 2.8.2)
|
||||||
@ -288,13 +292,14 @@ GEM
|
|||||||
nenv (~> 0.1)
|
nenv (~> 0.1)
|
||||||
shellany (~> 0.0)
|
shellany (~> 0.0)
|
||||||
orm_adapter (0.5.0)
|
orm_adapter (0.5.0)
|
||||||
parallel (1.21.0)
|
parallel (1.23.0)
|
||||||
parallel_tests (3.7.3)
|
parallel_tests (4.3.0)
|
||||||
parallel
|
parallel
|
||||||
parser (3.0.2.0)
|
parser (3.2.2.4)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
pg (1.2.3)
|
racc
|
||||||
pry (0.14.1)
|
pg (1.5.4)
|
||||||
|
pry (0.14.2)
|
||||||
coderay (~> 1.1)
|
coderay (~> 1.1)
|
||||||
method_source (~> 1.0)
|
method_source (~> 1.0)
|
||||||
pry-rails (0.3.9)
|
pry-rails (0.3.9)
|
||||||
@ -302,13 +307,15 @@ GEM
|
|||||||
pry-rescue (1.5.2)
|
pry-rescue (1.5.2)
|
||||||
interception (>= 0.5)
|
interception (>= 0.5)
|
||||||
pry (>= 0.12.0)
|
pry (>= 0.12.0)
|
||||||
public_suffix (4.0.6)
|
psych (5.1.1.1)
|
||||||
|
stringio
|
||||||
|
public_suffix (5.0.3)
|
||||||
puma (3.12.6)
|
puma (3.12.6)
|
||||||
pundit (2.1.1)
|
pundit (2.3.1)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.0)
|
||||||
racc (1.7.1)
|
racc (1.7.1)
|
||||||
rack (2.2.8)
|
rack (2.2.8)
|
||||||
rack-livereload (0.3.17)
|
rack-livereload (0.5.1)
|
||||||
rack
|
rack
|
||||||
rack-test (2.1.0)
|
rack-test (2.1.0)
|
||||||
rack (>= 1.3)
|
rack (>= 1.3)
|
||||||
@ -341,64 +348,69 @@ GEM
|
|||||||
method_source
|
method_source
|
||||||
rake (>= 12.2)
|
rake (>= 12.2)
|
||||||
thor (~> 1.0)
|
thor (~> 1.0)
|
||||||
rainbow (3.0.0)
|
rainbow (3.1.1)
|
||||||
rake (13.0.6)
|
rake (13.0.6)
|
||||||
rb-fchange (0.0.6)
|
rb-fchange (0.0.6)
|
||||||
ffi
|
ffi
|
||||||
rb-fsevent (0.11.0)
|
rb-fsevent (0.11.2)
|
||||||
rb-inotify (0.10.1)
|
rb-inotify (0.10.1)
|
||||||
ffi (~> 1.0)
|
ffi (~> 1.0)
|
||||||
rbnacl (3.4.0)
|
rbnacl (3.4.0)
|
||||||
ffi
|
ffi
|
||||||
rbnacl-libsodium (1.0.16)
|
rbnacl-libsodium (1.0.16)
|
||||||
rbnacl (>= 3.0.1)
|
rbnacl (>= 3.0.1)
|
||||||
rdoc (6.3.3)
|
rdoc (6.5.0)
|
||||||
react-rails (2.6.1)
|
psych (>= 4.0.0)
|
||||||
|
react-rails (3.1.1)
|
||||||
babel-transpiler (>= 0.7.0)
|
babel-transpiler (>= 0.7.0)
|
||||||
connection_pool
|
connection_pool
|
||||||
execjs
|
execjs
|
||||||
railties (>= 3.2)
|
railties (>= 3.2)
|
||||||
tilt
|
tilt
|
||||||
redis (4.8.1)
|
redis (4.8.1)
|
||||||
regexp_parser (2.1.1)
|
regexp_parser (2.8.2)
|
||||||
responders (3.0.1)
|
responders (3.1.1)
|
||||||
actionpack (>= 5.0)
|
|
||||||
railties (>= 5.0)
|
|
||||||
rexml (3.2.5)
|
|
||||||
rspec (3.10.0)
|
|
||||||
rspec-core (~> 3.10.0)
|
|
||||||
rspec-expectations (~> 3.10.0)
|
|
||||||
rspec-mocks (~> 3.10.0)
|
|
||||||
rspec-core (3.10.1)
|
|
||||||
rspec-support (~> 3.10.0)
|
|
||||||
rspec-expectations (3.10.1)
|
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
|
||||||
rspec-support (~> 3.10.0)
|
|
||||||
rspec-mocks (3.10.2)
|
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
|
||||||
rspec-support (~> 3.10.0)
|
|
||||||
rspec-rails (5.0.2)
|
|
||||||
actionpack (>= 5.2)
|
actionpack (>= 5.2)
|
||||||
activesupport (>= 5.2)
|
|
||||||
railties (>= 5.2)
|
railties (>= 5.2)
|
||||||
rspec-core (~> 3.10)
|
rexml (3.2.6)
|
||||||
rspec-expectations (~> 3.10)
|
rouge (4.1.3)
|
||||||
rspec-mocks (~> 3.10)
|
rspec (3.12.0)
|
||||||
rspec-support (~> 3.10)
|
rspec-core (~> 3.12.0)
|
||||||
rspec-support (3.10.3)
|
rspec-expectations (~> 3.12.0)
|
||||||
rubocop (1.22.3)
|
rspec-mocks (~> 3.12.0)
|
||||||
|
rspec-core (3.12.2)
|
||||||
|
rspec-support (~> 3.12.0)
|
||||||
|
rspec-expectations (3.12.3)
|
||||||
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
|
rspec-support (~> 3.12.0)
|
||||||
|
rspec-mocks (3.12.6)
|
||||||
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
|
rspec-support (~> 3.12.0)
|
||||||
|
rspec-rails (6.0.3)
|
||||||
|
actionpack (>= 6.1)
|
||||||
|
activesupport (>= 6.1)
|
||||||
|
railties (>= 6.1)
|
||||||
|
rspec-core (~> 3.12)
|
||||||
|
rspec-expectations (~> 3.12)
|
||||||
|
rspec-mocks (~> 3.12)
|
||||||
|
rspec-support (~> 3.12)
|
||||||
|
rspec-support (3.12.1)
|
||||||
|
rubocop (1.57.1)
|
||||||
|
base64 (~> 0.1.1)
|
||||||
|
json (~> 2.3)
|
||||||
|
language_server-protocol (>= 3.17.0)
|
||||||
parallel (~> 1.10)
|
parallel (~> 1.10)
|
||||||
parser (>= 3.0.0.0)
|
parser (>= 3.2.2.4)
|
||||||
rainbow (>= 2.2.2, < 4.0)
|
rainbow (>= 2.2.2, < 4.0)
|
||||||
regexp_parser (>= 1.8, < 3.0)
|
regexp_parser (>= 1.8, < 3.0)
|
||||||
rexml
|
rexml (>= 3.2.5, < 4.0)
|
||||||
rubocop-ast (>= 1.12.0, < 2.0)
|
rubocop-ast (>= 1.28.1, < 2.0)
|
||||||
ruby-progressbar (~> 1.7)
|
ruby-progressbar (~> 1.7)
|
||||||
unicode-display_width (>= 1.4.0, < 3.0)
|
unicode-display_width (>= 2.4.0, < 3.0)
|
||||||
rubocop-ast (1.13.0)
|
rubocop-ast (1.29.0)
|
||||||
parser (>= 3.0.1.1)
|
parser (>= 3.2.1.0)
|
||||||
ruby-progressbar (1.11.0)
|
ruby-progressbar (1.13.0)
|
||||||
ruby_parser (3.18.1)
|
ruby_parser (3.20.3)
|
||||||
sexp_processor (~> 4.16)
|
sexp_processor (~> 4.16)
|
||||||
rubyzip (2.3.2)
|
rubyzip (2.3.2)
|
||||||
sass-rails (6.0.0)
|
sass-rails (6.0.0)
|
||||||
@ -411,43 +423,44 @@ GEM
|
|||||||
sprockets (> 3.0)
|
sprockets (> 3.0)
|
||||||
sprockets-rails
|
sprockets-rails
|
||||||
tilt
|
tilt
|
||||||
sdoc (2.2.0)
|
sdoc (2.6.1)
|
||||||
rdoc (>= 5.0)
|
rdoc (>= 5.0)
|
||||||
selenium-webdriver (4.0.3)
|
selenium-webdriver (4.9.0)
|
||||||
childprocess (>= 0.5, < 5.0)
|
|
||||||
rexml (~> 3.2, >= 3.2.5)
|
rexml (~> 3.2, >= 3.2.5)
|
||||||
rubyzip (>= 1.2.2)
|
rubyzip (>= 1.2.2, < 3.0)
|
||||||
sexp_processor (4.16.0)
|
websocket (~> 1.0)
|
||||||
|
sexp_processor (4.17.0)
|
||||||
shellany (0.0.1)
|
shellany (0.0.1)
|
||||||
simple_form (5.1.0)
|
simple_form (5.3.0)
|
||||||
actionpack (>= 5.2)
|
actionpack (>= 5.2)
|
||||||
activemodel (>= 5.2)
|
activemodel (>= 5.2)
|
||||||
simplecov (0.21.2)
|
simplecov (0.22.0)
|
||||||
docile (~> 1.1)
|
docile (~> 1.1)
|
||||||
simplecov-html (~> 0.11)
|
simplecov-html (~> 0.11)
|
||||||
simplecov_json_formatter (~> 0.1)
|
simplecov_json_formatter (~> 0.1)
|
||||||
simplecov-html (0.12.3)
|
simplecov-html (0.12.3)
|
||||||
simplecov_json_formatter (0.1.3)
|
simplecov_json_formatter (0.1.4)
|
||||||
spring (3.0.0)
|
spring (4.1.1)
|
||||||
spring-commands-rspec (1.0.4)
|
spring-commands-rspec (1.0.4)
|
||||||
spring (>= 0.9.1)
|
spring (>= 0.9.1)
|
||||||
sprockets (3.7.2)
|
sprockets (4.2.1)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
rack (> 1, < 3)
|
rack (>= 2.2.4, < 4)
|
||||||
sprockets-rails (3.4.2)
|
sprockets-rails (3.4.2)
|
||||||
actionpack (>= 5.2)
|
actionpack (>= 5.2)
|
||||||
activesupport (>= 5.2)
|
activesupport (>= 5.2)
|
||||||
sprockets (>= 3.0.0)
|
sprockets (>= 3.0.0)
|
||||||
sshkit (1.21.2)
|
sshkit (1.21.5)
|
||||||
net-scp (>= 1.1.2)
|
net-scp (>= 1.1.2)
|
||||||
net-ssh (>= 2.8.0)
|
net-ssh (>= 2.8.0)
|
||||||
sshkit-interactive (0.3.0)
|
sshkit-interactive (0.3.0)
|
||||||
sshkit (~> 1.12)
|
sshkit (~> 1.12)
|
||||||
stimulus-rails (1.3.0)
|
stimulus-rails (1.3.0)
|
||||||
railties (>= 6.0.0)
|
railties (>= 6.0.0)
|
||||||
temple (0.8.2)
|
stringio (3.0.8)
|
||||||
thor (1.2.2)
|
temple (0.10.3)
|
||||||
tilt (2.0.10)
|
thor (1.3.0)
|
||||||
|
tilt (2.3.0)
|
||||||
timeout (0.4.0)
|
timeout (0.4.0)
|
||||||
turbo-rails (1.5.0)
|
turbo-rails (1.5.0)
|
||||||
actionpack (>= 6.0.0)
|
actionpack (>= 6.0.0)
|
||||||
@ -457,13 +470,14 @@ GEM
|
|||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
uglifier (4.2.0)
|
uglifier (4.2.0)
|
||||||
execjs (>= 0.3.0, < 3)
|
execjs (>= 0.3.0, < 3)
|
||||||
unicode-display_width (2.1.0)
|
unicode-display_width (2.5.0)
|
||||||
warden (1.2.9)
|
warden (1.2.9)
|
||||||
rack (>= 2.0.9)
|
rack (>= 2.0.9)
|
||||||
webdrivers (5.0.0)
|
webdrivers (5.3.1)
|
||||||
nokogiri (~> 1.6)
|
nokogiri (~> 1.6)
|
||||||
rubyzip (>= 1.3.0)
|
rubyzip (>= 1.3.0)
|
||||||
selenium-webdriver (~> 4.0)
|
selenium-webdriver (~> 4.0, < 4.11)
|
||||||
|
websocket (1.2.10)
|
||||||
websocket-driver (0.7.6)
|
websocket-driver (0.7.6)
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
@ -540,7 +554,7 @@ DEPENDENCIES
|
|||||||
simplecov
|
simplecov
|
||||||
spring
|
spring
|
||||||
spring-commands-rspec
|
spring-commands-rspec
|
||||||
sprockets (~> 3.7)
|
sprockets
|
||||||
stimulus-rails
|
stimulus-rails
|
||||||
turbo-rails
|
turbo-rails
|
||||||
uglifier
|
uglifier
|
||||||
|
|||||||
6
app/assets/config/manifest.js
Normal file
6
app/assets/config/manifest.js
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
//= link gems.js
|
||||||
|
//= link jquery.js
|
||||||
|
//= link_tree ../images
|
||||||
|
//= link_directory ../stylesheets .css
|
||||||
|
//= link_tree ../../javascript .js
|
||||||
|
//= link_tree ../../../vendor/assets/javascripts .js
|
||||||
File diff suppressed because one or more lines are too long
9
app/assets/javascripts/controllers/application.js
Normal file
9
app/assets/javascripts/controllers/application.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Application } from '@hotwired/stimulus'
|
||||||
|
|
||||||
|
const application = Application.start()
|
||||||
|
|
||||||
|
// Configure Stimulus development experience
|
||||||
|
application.debug = false
|
||||||
|
window.Stimulus = application
|
||||||
|
|
||||||
|
export { application }
|
||||||
5
app/assets/javascripts/controllers/index.js
Normal file
5
app/assets/javascripts/controllers/index.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { application } from 'controllers/application'
|
||||||
|
|
||||||
|
// Eager load all controllers defined in the import map under controllers/**/*_controller
|
||||||
|
import { eagerLoadControllersFrom } from '@hotwired/stimulus-loading'
|
||||||
|
eagerLoadControllersFrom('controllers', application)
|
||||||
17
app/assets/javascripts/controllers/medium_controller.js
Normal file
17
app/assets/javascripts/controllers/medium_controller.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
|
||||||
|
export default class MediumController extends Controller {
|
||||||
|
connect () {
|
||||||
|
this.reRenderMediaElement()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix potentially blank videos due to autoplay rules?
|
||||||
|
reRenderMediaElement () {
|
||||||
|
const mediaElement = this.mediaElementTarget
|
||||||
|
const clone = mediaElement.cloneNode(true)
|
||||||
|
mediaElement.parentNode.insertBefore(clone, mediaElement)
|
||||||
|
mediaElement.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MediumController.targets = ['mediaElement']
|
||||||
147
app/assets/javascripts/controllers/room_controller.js
Normal file
147
app/assets/javascripts/controllers/room_controller.js
Normal file
@ -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']
|
||||||
@ -17,10 +17,13 @@
|
|||||||
// require react
|
// require react
|
||||||
// require react_ujs
|
// require react_ujs
|
||||||
// require components
|
// require components
|
||||||
//= require qrcode.min.js
|
// require qrcode.min.js
|
||||||
//= require eyecandy
|
//= require eyecandy
|
||||||
//= require cable
|
// require cable
|
||||||
//= require bundle
|
// require_tree ./subscriptions
|
||||||
|
// require_tree ./models
|
||||||
|
// require_tree ./libraries
|
||||||
|
// require_tree ./controllers
|
||||||
// require react-rails-hot-loader
|
// require react-rails-hot-loader
|
||||||
// require_tree .
|
// require_tree .
|
||||||
|
|
||||||
0
app/assets/javascripts/libraries/.keep
Normal file
0
app/assets/javascripts/libraries/.keep
Normal file
5626
app/assets/javascripts/libraries/adapter.js
Normal file
5626
app/assets/javascripts/libraries/adapter.js
Normal file
File diff suppressed because it is too large
Load Diff
43
app/assets/javascripts/models/client.js
Normal file
43
app/assets/javascripts/models/client.js
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
183
app/assets/javascripts/models/webrtc_negotiation.js
Normal file
183
app/assets/javascripts/models/webrtc_negotiation.js
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
const RETRY_LIMIT = 10
|
||||||
|
const peerConnectionConfig = {
|
||||||
|
iceServers: [
|
||||||
|
{
|
||||||
|
urls: [
|
||||||
|
'stun:stun.l.google.com:19302',
|
||||||
|
'stun:global.stun.twilio.com:3478'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
sdpSemantics: 'unified-plan'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class WebrtcNegotiation {
|
||||||
|
constructor ({ client, otherClient, polite, signaller }) {
|
||||||
|
this.client = client
|
||||||
|
this.otherClient = otherClient
|
||||||
|
this.polite = polite
|
||||||
|
this.signaller = signaller
|
||||||
|
this.makingOffer = false
|
||||||
|
this.isSettingRemoteAnswerPending = false
|
||||||
|
this.candidates = []
|
||||||
|
this.retryCount = 0
|
||||||
|
|
||||||
|
this.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOffer () {
|
||||||
|
if (!this.readyToMakeOffer) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.makingOffer = true
|
||||||
|
await this.setLocalDescription(await this.peerConnection.createOffer())
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
this.makingOffer = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setDescription (description) {
|
||||||
|
try {
|
||||||
|
if (this.ignore(description)) return
|
||||||
|
|
||||||
|
await this.setRemoteDescription(description)
|
||||||
|
|
||||||
|
if (description.type === 'offer') {
|
||||||
|
await this.setLocalDescription(await this.peerConnection.createAnswer())
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (this.retryCount < RETRY_LIMIT) {
|
||||||
|
this.initiateManualRollback()
|
||||||
|
this.retryCount++
|
||||||
|
} else {
|
||||||
|
this.stop()
|
||||||
|
console.error(`Negotiation failed after ${this.retryCount} retries`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setLocalDescription (description) {
|
||||||
|
await this.peerConnection.setLocalDescription(description)
|
||||||
|
|
||||||
|
this.signaller.signal({
|
||||||
|
type: this.peerConnection.localDescription ? 'description' : undefined,
|
||||||
|
to: this.otherClient.id,
|
||||||
|
from: this.client.id,
|
||||||
|
description: this.peerConnection.localDescription
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async setRemoteDescription (description) {
|
||||||
|
this.isSettingRemoteAnswerPending = description.type === 'answer'
|
||||||
|
await this.peerConnection.setRemoteDescription(description) // SRD rolls back as needed
|
||||||
|
this.addCandidates()
|
||||||
|
this.isSettingRemoteAnswerPending = false
|
||||||
|
}
|
||||||
|
|
||||||
|
addCandidate (candidate) {
|
||||||
|
this.candidates.push(candidate)
|
||||||
|
this.addCandidates()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO try/catch and ignore failures if necessary?
|
||||||
|
addCandidates () {
|
||||||
|
if (this.peerConnection.remoteDescription) {
|
||||||
|
while (this.candidates.length) {
|
||||||
|
this.peerConnection.addIceCandidate(this.candidates.shift())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get readyToMakeOffer () {
|
||||||
|
return !this.makingOffer && this.peerConnection.signalingState === 'stable'
|
||||||
|
}
|
||||||
|
|
||||||
|
get readyToReceiveOffer () {
|
||||||
|
return !this.makingOffer && (
|
||||||
|
this.peerConnection.signalingState === 'stable' ||
|
||||||
|
this.isSettingRemoteAnswerPending
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
collides (description) {
|
||||||
|
return description.type === 'offer' && !this.readyToReceiveOffer
|
||||||
|
}
|
||||||
|
|
||||||
|
ignore (description) {
|
||||||
|
return !this.polite && this.collides(description)
|
||||||
|
}
|
||||||
|
|
||||||
|
initiateManualRollback() {
|
||||||
|
this.restart()
|
||||||
|
this.signaller.signal({
|
||||||
|
type: 'restart',
|
||||||
|
to: this.otherClient.id,
|
||||||
|
from: this.client.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
restart() {
|
||||||
|
this.stop()
|
||||||
|
this.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
start () {
|
||||||
|
this.setupPeerConnection()
|
||||||
|
}
|
||||||
|
|
||||||
|
stop () {
|
||||||
|
this.makingOffer = false
|
||||||
|
this.isSettingRemoteAnswerPending = false
|
||||||
|
this.candidates = []
|
||||||
|
this.teardownPeerConnection()
|
||||||
|
this.otherClient.streaming = false
|
||||||
|
}
|
||||||
|
|
||||||
|
setupPeerConnection () {
|
||||||
|
this.peerConnection = new RTCPeerConnection(peerConnectionConfig)
|
||||||
|
|
||||||
|
this._onnegotiationneeded = () => this.createOffer()
|
||||||
|
|
||||||
|
this._onicecandidate = ({ candidate }) => {
|
||||||
|
this.signaller.signal({
|
||||||
|
type: candidate ? 'candidate' : undefined,
|
||||||
|
to: this.otherClient.id,
|
||||||
|
from: this.client.id,
|
||||||
|
candidate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this._oniceconnectionstatechange = (event) => {
|
||||||
|
this.client.broadcast(`iceConnection:${this.peerConnection.iceConnectionState}`, { otherClient: this.otherClient })
|
||||||
|
|
||||||
|
switch (this.peerConnection.iceConnectionState) {
|
||||||
|
case 'completed':
|
||||||
|
this.retryCount = 0
|
||||||
|
break;
|
||||||
|
case 'failed':
|
||||||
|
if ('restartIce' in this.peerConnection) this.peerConnection.restartIce()
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._ontrack = (event) => this.otherClient.broadcast('track', event)
|
||||||
|
|
||||||
|
this.peerConnection.addEventListener('negotiationneeded', this._onnegotiationneeded)
|
||||||
|
this.peerConnection.addEventListener('icecandidate', this._onicecandidate)
|
||||||
|
this.peerConnection.addEventListener('iceconnectionstatechange', this._oniceconnectionstatechange)
|
||||||
|
this.peerConnection.addEventListener('track', this._ontrack)
|
||||||
|
}
|
||||||
|
|
||||||
|
teardownPeerConnection () {
|
||||||
|
this.peerConnection.close()
|
||||||
|
this.peerConnection.removeEventListener('negotiationneeded', this._onnegotiationneeded)
|
||||||
|
this.peerConnection.removeEventListener('icecandidate', this._onicecandidate)
|
||||||
|
this.peerConnection.removeEventListener('iceconnectionstatechange', this._oniceconnectionstatechange)
|
||||||
|
this.peerConnection.removeEventListener('track', this._ontrack)
|
||||||
|
this.peerConnection = null
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/assets/javascripts/subscriptions/room_subscription.js
Normal file
37
app/assets/javascripts/subscriptions/room_subscription.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { cable } from '@hotwired/turbo-rails'
|
||||||
|
|
||||||
|
export default class RoomSubscription {
|
||||||
|
constructor ({ delegate, id, clientId }) {
|
||||||
|
this.delegate = delegate
|
||||||
|
this.id = id
|
||||||
|
this.clientId = clientId
|
||||||
|
}
|
||||||
|
|
||||||
|
async start () {
|
||||||
|
const self = this
|
||||||
|
|
||||||
|
this.subscription = await cable.subscribeTo({
|
||||||
|
channel: 'RoomChannel',
|
||||||
|
id: this.id,
|
||||||
|
client_id: this.clientId
|
||||||
|
}, {
|
||||||
|
greet (data) {
|
||||||
|
this.perform('greet', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
received (data) {
|
||||||
|
// Ignore self-sent data
|
||||||
|
if (data.from === self.clientId) return
|
||||||
|
|
||||||
|
if (data.type === 'ping') self.delegate.roomPinged(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.started = true
|
||||||
|
}
|
||||||
|
|
||||||
|
greet (data) {
|
||||||
|
if (!this.started) return
|
||||||
|
this.subscription.greet(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
import { cable } from '@hotwired/turbo-rails'
|
||||||
|
|
||||||
|
export default class SignalingSubscription {
|
||||||
|
constructor({ delegate, id, clientId }) {
|
||||||
|
this.callbacks = {}
|
||||||
|
this.delegate = delegate
|
||||||
|
this.id = id
|
||||||
|
this.clientId = clientId
|
||||||
|
}
|
||||||
|
|
||||||
|
async start () {
|
||||||
|
const self = this
|
||||||
|
|
||||||
|
this.subscription = await cable.subscribeTo({
|
||||||
|
channel: 'SignalingChannel',
|
||||||
|
id: this.id
|
||||||
|
}, {
|
||||||
|
received (data) {
|
||||||
|
const { to, from, type, description, candidate } = data
|
||||||
|
if (to != self.clientId) return
|
||||||
|
|
||||||
|
if (description) return self.delegate.sdpDescriptionReceived({ from, description })
|
||||||
|
if (candidate) return self.delegate.iceCandidateReceived({ from, candidate })
|
||||||
|
if (type === 'restart') return self.delegate.negotiationRestarted({ from })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.started = true
|
||||||
|
}
|
||||||
|
|
||||||
|
signal (data) {
|
||||||
|
if (!this.started) return
|
||||||
|
this.subscription.perform('signal', data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,4 +1,15 @@
|
|||||||
module ApplicationCable
|
module ApplicationCable
|
||||||
class Connection < ActionCable::Connection::Base
|
class Connection < ActionCable::Connection::Base
|
||||||
|
identified_by :current_client
|
||||||
|
|
||||||
|
def connect
|
||||||
|
self.current_client = find_client
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def find_client
|
||||||
|
Client.new(id: cookies.encrypted[:client_id])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
29
app/channels/room_channel.rb
Normal file
29
app/channels/room_channel.rb
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
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)
|
||||||
|
Turbo::StreamsChannel.broadcast_append_to(
|
||||||
|
data['to'],
|
||||||
|
target: 'media',
|
||||||
|
partial: 'media/medium',
|
||||||
|
locals: { client_id: data['from'] }
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def find_room
|
||||||
|
Room.new(id: params[:id])
|
||||||
|
end
|
||||||
|
end
|
||||||
9
app/channels/signaling_channel.rb
Normal file
9
app/channels/signaling_channel.rb
Normal file
@ -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
|
||||||
16
app/controllers/rooms_controller.rb
Normal file
16
app/controllers/rooms_controller.rb
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
class RoomsController < ApplicationController
|
||||||
|
before_action :skip_authorization
|
||||||
|
|
||||||
|
def new
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
redirect_to room_path(SecureRandom.uuid)
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
@client = Client.new(id: SecureRandom.uuid)
|
||||||
|
cookies.encrypted[:client_id] = @client.id
|
||||||
|
@room = Room.new(id: params[:id])
|
||||||
|
end
|
||||||
|
end
|
||||||
1
app/javascript/application.js
Normal file
1
app/javascript/application.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
import 'controllers'
|
||||||
9
app/javascript/controllers/application.js
Normal file
9
app/javascript/controllers/application.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { Application } from '@hotwired/stimulus'
|
||||||
|
|
||||||
|
const application = Application.start()
|
||||||
|
|
||||||
|
// Configure Stimulus development experience
|
||||||
|
application.debug = false
|
||||||
|
window.Stimulus = application
|
||||||
|
|
||||||
|
export { application }
|
||||||
5
app/javascript/controllers/index.js
Normal file
5
app/javascript/controllers/index.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { application } from 'controllers/application'
|
||||||
|
|
||||||
|
// Eager load all controllers defined in the import map under controllers/**/*_controller
|
||||||
|
import { eagerLoadControllersFrom } from '@hotwired/stimulus-loading'
|
||||||
|
eagerLoadControllersFrom('controllers', application)
|
||||||
17
app/javascript/controllers/medium_controller.js
Normal file
17
app/javascript/controllers/medium_controller.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
|
||||||
|
export default class MediumController extends Controller {
|
||||||
|
connect () {
|
||||||
|
this.reRenderMediaElement()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fix potentially blank videos due to autoplay rules?
|
||||||
|
reRenderMediaElement () {
|
||||||
|
const mediaElement = this.mediaElementTarget
|
||||||
|
const clone = mediaElement.cloneNode(true)
|
||||||
|
mediaElement.parentNode.insertBefore(clone, mediaElement)
|
||||||
|
mediaElement.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MediumController.targets = ['mediaElement']
|
||||||
147
app/javascript/controllers/room_controller.js
Normal file
147
app/javascript/controllers/room_controller.js
Normal file
@ -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']
|
||||||
0
app/javascript/libraries/.keep
Normal file
0
app/javascript/libraries/.keep
Normal file
5626
app/javascript/libraries/adapter.js
Normal file
5626
app/javascript/libraries/adapter.js
Normal file
File diff suppressed because it is too large
Load Diff
43
app/javascript/models/client.js
Normal file
43
app/javascript/models/client.js
Normal file
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
183
app/javascript/models/webrtc_negotiation.js
Normal file
183
app/javascript/models/webrtc_negotiation.js
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
const RETRY_LIMIT = 10
|
||||||
|
const peerConnectionConfig = {
|
||||||
|
iceServers: [
|
||||||
|
{
|
||||||
|
urls: [
|
||||||
|
'stun:stun.l.google.com:19302',
|
||||||
|
'stun:global.stun.twilio.com:3478'
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
sdpSemantics: 'unified-plan'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class WebrtcNegotiation {
|
||||||
|
constructor ({ client, otherClient, polite, signaller }) {
|
||||||
|
this.client = client
|
||||||
|
this.otherClient = otherClient
|
||||||
|
this.polite = polite
|
||||||
|
this.signaller = signaller
|
||||||
|
this.makingOffer = false
|
||||||
|
this.isSettingRemoteAnswerPending = false
|
||||||
|
this.candidates = []
|
||||||
|
this.retryCount = 0
|
||||||
|
|
||||||
|
this.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOffer () {
|
||||||
|
if (!this.readyToMakeOffer) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.makingOffer = true
|
||||||
|
await this.setLocalDescription(await this.peerConnection.createOffer())
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error)
|
||||||
|
} finally {
|
||||||
|
this.makingOffer = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setDescription (description) {
|
||||||
|
try {
|
||||||
|
if (this.ignore(description)) return
|
||||||
|
|
||||||
|
await this.setRemoteDescription(description)
|
||||||
|
|
||||||
|
if (description.type === 'offer') {
|
||||||
|
await this.setLocalDescription(await this.peerConnection.createAnswer())
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (this.retryCount < RETRY_LIMIT) {
|
||||||
|
this.initiateManualRollback()
|
||||||
|
this.retryCount++
|
||||||
|
} else {
|
||||||
|
this.stop()
|
||||||
|
console.error(`Negotiation failed after ${this.retryCount} retries`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setLocalDescription (description) {
|
||||||
|
await this.peerConnection.setLocalDescription(description)
|
||||||
|
|
||||||
|
this.signaller.signal({
|
||||||
|
type: this.peerConnection.localDescription ? 'description' : undefined,
|
||||||
|
to: this.otherClient.id,
|
||||||
|
from: this.client.id,
|
||||||
|
description: this.peerConnection.localDescription
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async setRemoteDescription (description) {
|
||||||
|
this.isSettingRemoteAnswerPending = description.type === 'answer'
|
||||||
|
await this.peerConnection.setRemoteDescription(description) // SRD rolls back as needed
|
||||||
|
this.addCandidates()
|
||||||
|
this.isSettingRemoteAnswerPending = false
|
||||||
|
}
|
||||||
|
|
||||||
|
addCandidate (candidate) {
|
||||||
|
this.candidates.push(candidate)
|
||||||
|
this.addCandidates()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO try/catch and ignore failures if necessary?
|
||||||
|
addCandidates () {
|
||||||
|
if (this.peerConnection.remoteDescription) {
|
||||||
|
while (this.candidates.length) {
|
||||||
|
this.peerConnection.addIceCandidate(this.candidates.shift())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get readyToMakeOffer () {
|
||||||
|
return !this.makingOffer && this.peerConnection.signalingState === 'stable'
|
||||||
|
}
|
||||||
|
|
||||||
|
get readyToReceiveOffer () {
|
||||||
|
return !this.makingOffer && (
|
||||||
|
this.peerConnection.signalingState === 'stable' ||
|
||||||
|
this.isSettingRemoteAnswerPending
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
collides (description) {
|
||||||
|
return description.type === 'offer' && !this.readyToReceiveOffer
|
||||||
|
}
|
||||||
|
|
||||||
|
ignore (description) {
|
||||||
|
return !this.polite && this.collides(description)
|
||||||
|
}
|
||||||
|
|
||||||
|
initiateManualRollback() {
|
||||||
|
this.restart()
|
||||||
|
this.signaller.signal({
|
||||||
|
type: 'restart',
|
||||||
|
to: this.otherClient.id,
|
||||||
|
from: this.client.id
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
restart() {
|
||||||
|
this.stop()
|
||||||
|
this.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
start () {
|
||||||
|
this.setupPeerConnection()
|
||||||
|
}
|
||||||
|
|
||||||
|
stop () {
|
||||||
|
this.makingOffer = false
|
||||||
|
this.isSettingRemoteAnswerPending = false
|
||||||
|
this.candidates = []
|
||||||
|
this.teardownPeerConnection()
|
||||||
|
this.otherClient.streaming = false
|
||||||
|
}
|
||||||
|
|
||||||
|
setupPeerConnection () {
|
||||||
|
this.peerConnection = new RTCPeerConnection(peerConnectionConfig)
|
||||||
|
|
||||||
|
this._onnegotiationneeded = () => this.createOffer()
|
||||||
|
|
||||||
|
this._onicecandidate = ({ candidate }) => {
|
||||||
|
this.signaller.signal({
|
||||||
|
type: candidate ? 'candidate' : undefined,
|
||||||
|
to: this.otherClient.id,
|
||||||
|
from: this.client.id,
|
||||||
|
candidate
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
this._oniceconnectionstatechange = (event) => {
|
||||||
|
this.client.broadcast(`iceConnection:${this.peerConnection.iceConnectionState}`, { otherClient: this.otherClient })
|
||||||
|
|
||||||
|
switch (this.peerConnection.iceConnectionState) {
|
||||||
|
case 'completed':
|
||||||
|
this.retryCount = 0
|
||||||
|
break;
|
||||||
|
case 'failed':
|
||||||
|
if ('restartIce' in this.peerConnection) this.peerConnection.restartIce()
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this._ontrack = (event) => this.otherClient.broadcast('track', event)
|
||||||
|
|
||||||
|
this.peerConnection.addEventListener('negotiationneeded', this._onnegotiationneeded)
|
||||||
|
this.peerConnection.addEventListener('icecandidate', this._onicecandidate)
|
||||||
|
this.peerConnection.addEventListener('iceconnectionstatechange', this._oniceconnectionstatechange)
|
||||||
|
this.peerConnection.addEventListener('track', this._ontrack)
|
||||||
|
}
|
||||||
|
|
||||||
|
teardownPeerConnection () {
|
||||||
|
this.peerConnection.close()
|
||||||
|
this.peerConnection.removeEventListener('negotiationneeded', this._onnegotiationneeded)
|
||||||
|
this.peerConnection.removeEventListener('icecandidate', this._onicecandidate)
|
||||||
|
this.peerConnection.removeEventListener('iceconnectionstatechange', this._oniceconnectionstatechange)
|
||||||
|
this.peerConnection.removeEventListener('track', this._ontrack)
|
||||||
|
this.peerConnection = null
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/javascript/subscriptions/room_subscription.js
Normal file
37
app/javascript/subscriptions/room_subscription.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { cable } from '@hotwired/turbo-rails'
|
||||||
|
|
||||||
|
export default class RoomSubscription {
|
||||||
|
constructor ({ delegate, id, clientId }) {
|
||||||
|
this.delegate = delegate
|
||||||
|
this.id = id
|
||||||
|
this.clientId = clientId
|
||||||
|
}
|
||||||
|
|
||||||
|
async start () {
|
||||||
|
const self = this
|
||||||
|
|
||||||
|
this.subscription = await cable.subscribeTo({
|
||||||
|
channel: 'RoomChannel',
|
||||||
|
id: this.id,
|
||||||
|
client_id: this.clientId
|
||||||
|
}, {
|
||||||
|
greet (data) {
|
||||||
|
this.perform('greet', data)
|
||||||
|
},
|
||||||
|
|
||||||
|
received (data) {
|
||||||
|
// Ignore self-sent data
|
||||||
|
if (data.from === self.clientId) return
|
||||||
|
|
||||||
|
if (data.type === 'ping') self.delegate.roomPinged(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.started = true
|
||||||
|
}
|
||||||
|
|
||||||
|
greet (data) {
|
||||||
|
if (!this.started) return
|
||||||
|
this.subscription.greet(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/javascript/subscriptions/signaling_subscription.js
Normal file
35
app/javascript/subscriptions/signaling_subscription.js
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { cable } from '@hotwired/turbo-rails'
|
||||||
|
|
||||||
|
export default class SignalingSubscription {
|
||||||
|
constructor({ delegate, id, clientId }) {
|
||||||
|
this.callbacks = {}
|
||||||
|
this.delegate = delegate
|
||||||
|
this.id = id
|
||||||
|
this.clientId = clientId
|
||||||
|
}
|
||||||
|
|
||||||
|
async start () {
|
||||||
|
const self = this
|
||||||
|
|
||||||
|
this.subscription = await cable.subscribeTo({
|
||||||
|
channel: 'SignalingChannel',
|
||||||
|
id: this.id
|
||||||
|
}, {
|
||||||
|
received (data) {
|
||||||
|
const { to, from, type, description, candidate } = data
|
||||||
|
if (to != self.clientId) return
|
||||||
|
|
||||||
|
if (description) return self.delegate.sdpDescriptionReceived({ from, description })
|
||||||
|
if (candidate) return self.delegate.iceCandidateReceived({ from, candidate })
|
||||||
|
if (type === 'restart') return self.delegate.negotiationRestarted({ from })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.started = true
|
||||||
|
}
|
||||||
|
|
||||||
|
signal (data) {
|
||||||
|
if (!this.started) return
|
||||||
|
this.subscription.perform('signal', data)
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/models/client.rb
Normal file
11
app/models/client.rb
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
class Client
|
||||||
|
attr_reader :id
|
||||||
|
|
||||||
|
def initialize(id:)
|
||||||
|
@id = id
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_param
|
||||||
|
id
|
||||||
|
end
|
||||||
|
end
|
||||||
11
app/models/room.rb
Normal file
11
app/models/room.rb
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
class Room
|
||||||
|
attr_reader :id
|
||||||
|
|
||||||
|
def initialize(id:)
|
||||||
|
@id = id
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_param
|
||||||
|
id
|
||||||
|
end
|
||||||
|
end
|
||||||
@ -6,7 +6,8 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Anton&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Anton&display=swap" rel="stylesheet">
|
||||||
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %>
|
<%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track' => true %>
|
||||||
<%= javascript_include_tag 'application', 'data-turbolinks-track' => true %>
|
<%= javascript_include_tag 'gems', 'data-turbolinks-track' => true %>
|
||||||
|
<%= javascript_importmap_tags %>
|
||||||
<%= csrf_meta_tags %>
|
<%= csrf_meta_tags %>
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
8
app/views/media/_medium.html.erb
Normal file
8
app/views/media/_medium.html.erb
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<div
|
||||||
|
class="medium"
|
||||||
|
id="medium_<%= client_id %>"
|
||||||
|
data-controller="medium"
|
||||||
|
data-room-target="remoteMedium"
|
||||||
|
>
|
||||||
|
<video autoplay playsinline data-medium-target="mediaElement"></video>
|
||||||
|
</div>
|
||||||
3
app/views/rooms/new.html.erb
Normal file
3
app/views/rooms/new.html.erb
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<%= form_with url: rooms_path do |form| %>
|
||||||
|
<%= form.submit 'Create Room' %>
|
||||||
|
<% end %>
|
||||||
19
app/views/rooms/show.html.erb
Normal file
19
app/views/rooms/show.html.erb
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<%= turbo_stream_from @room %>
|
||||||
|
<%= turbo_stream_from @client.id %>
|
||||||
|
|
||||||
|
<article
|
||||||
|
class="room"
|
||||||
|
data-controller="room"
|
||||||
|
data-room-id-value="<%= @room.id %>"
|
||||||
|
data-room-client-id-value="<%= @client.id %>"
|
||||||
|
>
|
||||||
|
<div class="media" id="media">
|
||||||
|
<div class="medium medium--local" data-controller="medium">
|
||||||
|
<video data-room-target="localMedium" data-medium-target="mediaElement" muted autoplay playsinline></video>
|
||||||
|
|
||||||
|
<div class="medium-actions">
|
||||||
|
<%= button_tag 'Enter', data: { room_target: 'enter', action: 'room#enter' } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
8
config/importmap.rb
Normal file
8
config/importmap.rb
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
pin 'application', preload: true
|
||||||
|
pin 'jquery'
|
||||||
|
pin '@hotwired/turbo-rails', to: "turbo.js"
|
||||||
|
pin '@hotwired/stimulus', to: 'stimulus.min.js', preload: true
|
||||||
|
pin '@hotwired/stimulus-loading', to: 'stimulus-loading.js', preload: true
|
||||||
|
pin_all_from 'app/javascript/models', under: 'models'
|
||||||
|
pin_all_from 'app/javascript/controllers', under: 'controllers'
|
||||||
|
pin_all_from 'app/javascript/subscriptions', under: 'subscriptions'
|
||||||
@ -56,4 +56,5 @@ Rails.application.routes.draw do
|
|||||||
get '/calls', to: 'calls#root'
|
get '/calls', to: 'calls#root'
|
||||||
resources :calls, only: :create
|
resources :calls, only: :create
|
||||||
mount ActionCable.server, at: '/cable'
|
mount ActionCable.server, at: '/cable'
|
||||||
|
resources :rooms, only: [:new, :create, :show]
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,9 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import VideoCall from './components/VideoCall';
|
|
||||||
import ReactDOM from 'react-dom';
|
|
||||||
import { createRoot } from 'react-dom/client';
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
|
||||||
// alert("hi");
|
|
||||||
const root = createRoot(document.getElementById("root"));
|
|
||||||
root.render(<VideoCall />)
|
|
||||||
});
|
|
||||||
@ -1,153 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { broadcastData, JOIN_CALL, LEAVE_CALL, EXCHANGE, ice } from './video_util.js';
|
|
||||||
|
|
||||||
class VideoCall extends React.Component{
|
|
||||||
constructor(props){
|
|
||||||
super(props);
|
|
||||||
this.pcPeers = {};
|
|
||||||
this.userId = Math.floor(Math.random() * 10000);
|
|
||||||
this.joinCall = this.joinCall.bind(this);
|
|
||||||
this.leaveCall = this.leaveCall.bind(this);
|
|
||||||
}
|
|
||||||
componentDidMount(){
|
|
||||||
this.remoteVideoContainer =
|
|
||||||
document.getElementById("remote-video-container")
|
|
||||||
navigator.mediaDevices.getUserMedia({audio: false, video: true})
|
|
||||||
.then(stream => {
|
|
||||||
this.localStream = stream;
|
|
||||||
document.getElementById("local-video").srcObject = stream;
|
|
||||||
}).catch(error => { console.log(error)});
|
|
||||||
}
|
|
||||||
join(data){ this.createPC(data.from, true) }
|
|
||||||
joinCall(e){
|
|
||||||
console.log("Hello from joinCall");
|
|
||||||
// console.log(e);
|
|
||||||
App.cable.subscriptions.create(
|
|
||||||
{ channel: "CallChannel" },
|
|
||||||
{
|
|
||||||
connected: () => {
|
|
||||||
broadcastData({ type: JOIN_CALL, from: this.userId});
|
|
||||||
},
|
|
||||||
received: data => {
|
|
||||||
console.log("RECEIVED: ", data);
|
|
||||||
if (data.from === this.userId) return;
|
|
||||||
switch(data.type){
|
|
||||||
case JOIN_CALL:
|
|
||||||
return this.join(data);
|
|
||||||
case EXCHANGE:
|
|
||||||
if (data.to !== this.userId) return;
|
|
||||||
return this.exchange(data);
|
|
||||||
case LEAVE_CALL:
|
|
||||||
return this.removeUser(data);
|
|
||||||
default:
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
createPC(userId, offerBool){
|
|
||||||
const pc = new RTCPeerConnection(ice);
|
|
||||||
this.pcPeers[userId] = pc;
|
|
||||||
this.localStream.getTracks()
|
|
||||||
.forEach(track => pc.addTrack(track, this.localStream));
|
|
||||||
if (offerBool) {
|
|
||||||
pc.createOffer().then(offer => {
|
|
||||||
pc.setLocalDescription(offer).then(() => {
|
|
||||||
setTimeout( () => {
|
|
||||||
broadcastData({
|
|
||||||
type: EXCHANGE,
|
|
||||||
from: this.userId,
|
|
||||||
to: userId,
|
|
||||||
sdp: JSON.stringify(pc.localDescription),
|
|
||||||
});
|
|
||||||
}, 0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
pc.onicecandidate = (e) => {
|
|
||||||
broadcastData({
|
|
||||||
type: EXCHANGE,
|
|
||||||
from: this.userId,
|
|
||||||
to: userId,
|
|
||||||
sdp: JSON.stringify(e.candidate)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
pc.ontrack = (e) => {
|
|
||||||
const remoteVid = document.createElement("video");
|
|
||||||
remoteVid.id = `remoteVideoContainer+${userId}`;
|
|
||||||
remoteVid.autoplay = "autoplay";
|
|
||||||
remoteVid.srcObject = e.streams[0];
|
|
||||||
this.remoteVideoContainer.appendChild(remoteVid);
|
|
||||||
}
|
|
||||||
pc.oniceconnectionstatechange = (e) => {
|
|
||||||
if (pc.iceConnectionState === 'disconnected'){
|
|
||||||
broadcastData({ type: LEAVE_CALL, from: userId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return pc;
|
|
||||||
}
|
|
||||||
|
|
||||||
exchange(data){
|
|
||||||
let pc;
|
|
||||||
if(this.pcPeers[data.from]){
|
|
||||||
pc = this.pcPeers[data.from];
|
|
||||||
} else{
|
|
||||||
pc = this.createPC(data.from, false);
|
|
||||||
}
|
|
||||||
if (data.candidate){
|
|
||||||
let candidate = JSON.parse(data.candidate)
|
|
||||||
pc.addIceCandidate(new RTCIceCandidate(candidate))
|
|
||||||
}
|
|
||||||
if(data.sdp){
|
|
||||||
const sdp = JSON.parse(data.sdp);
|
|
||||||
if(sdp && !sdp.candidate){
|
|
||||||
pc.setRemoteDescription(sdp).then( () =>{
|
|
||||||
if (sdp.type === 'offer'){
|
|
||||||
pc.createAnswer().then(answer => {
|
|
||||||
pc.setLocalDescription(answer)
|
|
||||||
.then( () => {
|
|
||||||
broadcastData({
|
|
||||||
type: EXCHANGE,
|
|
||||||
from: this.userId,
|
|
||||||
to: data.from,
|
|
||||||
sdp: JSON.stringify(pc.localDescription)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
leaveCall(e){
|
|
||||||
const pcKeys = Object.keys(this.pcPeers);
|
|
||||||
for (let i = 0; i < pcKeys.length; i++) {
|
|
||||||
this.pcPeers[pcKeys[i]].close();
|
|
||||||
}
|
|
||||||
this.pcPeers = {};
|
|
||||||
this.localVideo.srcObject.getTracks()
|
|
||||||
.forEach(function (track) { track.stop(); })
|
|
||||||
|
|
||||||
this.localVideo.srcObject = null;
|
|
||||||
App.cable.subscriptions.subscriptions = [];
|
|
||||||
this.remoteVideoContainer.innerHTML = "";
|
|
||||||
broadcastData({ type: LEAVE_CALL, from: this.userId });
|
|
||||||
}
|
|
||||||
removeUser(data){
|
|
||||||
let video = document.getElementById(`remoteVideoContainer+${data.from}`);
|
|
||||||
video && video.remove();
|
|
||||||
let peers = this.pcPeers
|
|
||||||
delete peers[data.from]
|
|
||||||
}
|
|
||||||
render(){
|
|
||||||
return(
|
|
||||||
<div className="VideoCall">
|
|
||||||
<div id="remote-video-container"></div>
|
|
||||||
<video id="local-video" autoPlay></video>
|
|
||||||
<button onClick={this.joinCall}>Join Call</button>
|
|
||||||
<button onClick={this.leaveCall}>Leave Call</button>
|
|
||||||
</div>)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
export default VideoCall;
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
export const JOIN_CALL = "JOIN_CALL";
|
|
||||||
export const EXCHANGE = "EXCHANGE";
|
|
||||||
export const LEAVE_CALL = "LEAVE_CALL";
|
|
||||||
export const ice = { iceServers: [
|
|
||||||
{
|
|
||||||
urls: "stun:stun2.l.google.com:19302"
|
|
||||||
}
|
|
||||||
]};
|
|
||||||
export const broadcastData = data => {
|
|
||||||
fetch("calls", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"X-CSRF-Token": document.querySelector("meta[name='csrf-token']").content,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -20,7 +20,7 @@
|
|||||||
"babel-loader": "^9.1.3",
|
"babel-loader": "^9.1.3",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"webpack": "^5.88.2",
|
// "webpack": "^5.88.2",
|
||||||
"webpack-cli": "^5.1.4"
|
// "webpack-cli": "^5.1.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
var path = require('path');
|
|
||||||
module.exports = {
|
|
||||||
entry: "./frontend/VideoComponent.jsx",
|
|
||||||
output: {
|
|
||||||
path: path.resolve(__dirname, 'app', 'assets', 'javascripts'),
|
|
||||||
filename: "bundle.js"
|
|
||||||
},
|
|
||||||
module: {
|
|
||||||
rules: [{
|
|
||||||
test: [/\.jsx?$/],
|
|
||||||
exclude: /(node_modules)/,
|
|
||||||
use: {
|
|
||||||
loader: 'babel-loader',
|
|
||||||
options: { presets: ['@babel/env', '@babel/react'] }
|
|
||||||
},
|
|
||||||
}]
|
|
||||||
},
|
|
||||||
devtool: 'eval-source-map',
|
|
||||||
resolve: { extensions: ['.js', '.jsx', '*']}
|
|
||||||
};
|
|
||||||
Loading…
x
Reference in New Issue
Block a user