Skip to content

Commit 50db9ab

Browse files
committed
Fix ghost iceConnectionState changes when remote client has left.
Replace event system with TargetObserver as proposed in hotwired/stimulus#409 and teardown clients when the remote video is removed
1 parent 738baaa commit 50db9ab

File tree

6 files changed

+128
-27
lines changed

6 files changed

+128
-27
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ An basic video chat app using the [WebRTC Perfect Negotiation pattern](https://w
44
## How does it work?
55
The Stimulus room controller handles Enter button click. It gets the user's local audio and video and feeds them into the local video element. It also starts Action Cable subscriptions specific to the current room: one for communicating WebRTC messages: the `Signaller`; and one for clients to ping others: the `RoomSubscription`. Once connected, the room channel broadcasts the presence of this new client. Each connected client then "greets" this newcomer by calling the `greet` action on the room channel and specifying `to` and `from` fields.
66

7-
The `greet` action broadcasts specifically to the newcomer, a Turbo Stream update which appends a media element representing the remote client. It's important we do this first so that it's ready for incoming streams. The media controller broadcasts it's connected (using a snippet taken from the hey.com source code, which will be simplified once [we can listen for adding/removing Stimulus targets](https://github.com/hotwired/stimulus/pull/367)). This begins the WebRTC negotiation to form a connection between the two clients.
7+
The `greet` action broadcasts specifically to the newcomer, a Turbo Stream update which appends a media element representing the remote client. It's important we do this first so that it's ready for incoming streams. The room controller detects that a remote video has been added (by patching Stimulus with code from: https://github.com/hotwired/stimulus/pull/409). This begins the WebRTC negotiation to form a connection between the two clients.
88

99
The WebRTC negotiation is quite complex, and even though things are required to happen in a particular order, responses are triggered asynchronously, making it tricky to get right. This is where the [WebRTC Perfect Negotiation pattern](https://w3c.github.io/webrtc-pc/#perfect-negotiation-example) comes in. We won't go into it too much here, as it's covered well elsewhere; but for the purpose of this description, (_deep breath_), Session Description Protocol (SDP) descriptions and Interactive Connectivity Establishment (ICE) candidates are exchanged over the `Signaller`. The negotiation is "perfect" as it ensures that the `RTCPeerConnection` is in the correct state for setting descriptions, and avoids collision errors by having one client be "polite" and other "impolite"—the polite one backs down in the case of a collision. Anyway, the code for this has been mostly lifted from the spec and handled by the `Signaller`/`WebrtcNegotiation`. When creating the negotition, the room controller listens for `track` events on the `RTCPeerConnection`, so it can start streaming from the remote client.
1010

app/assets/javascripts/controllers/medium_controller.js

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,15 @@ import { Controller } from 'stimulus'
33
export default class MediumController extends Controller {
44
connect () {
55
this.reRenderMediaElement()
6-
this.dispatch('connect', {
7-
detail: { clientId: this.clientIdValue }
8-
})
96
}
107

11-
128
// Fix potentially blank videos due to autoplay rules?
139
reRenderMediaElement () {
1410
const mediaElement = this.mediaElementTarget
1511
const clone = mediaElement.cloneNode(true)
1612
mediaElement.parentNode.insertBefore(clone, mediaElement)
1713
mediaElement.remove()
1814
}
19-
20-
disconnect () {
21-
this.dispatch('disconnect', {
22-
detail: { clientId: this.clientIdValue }
23-
})
24-
}
25-
26-
dispatch (eventName, { target = this.element, detail = {}, bubbles = true, cancelable = true } = {}) {
27-
const type = `${this.identifier}:${eventName}`
28-
const event = new CustomEvent(type, { detail, bubbles, cancelable })
29-
target.dispatchEvent(event)
30-
return event
31-
}
3215
}
3316

3417
MediumController.values = { clientId: String }

app/assets/javascripts/controllers/room_controller.js

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import { Controller } from 'stimulus'
2+
import TargetObserver from 'target_observer'
23
import Client from 'client'
34
import WebrtcNegotiation from 'webrtc_negotiation'
45
import RoomSubscription from 'room_subscription'
56
import Signaller from 'webrtc_session_subscription'
67

78
export default class RoomController extends Controller {
9+
initialize () {
10+
this.targetObserver = new TargetObserver(this.context, this.context)
11+
}
812
connect() {
13+
this.targetObserver.start()
14+
915
this.clients = {}
1016
this.client = new Client(this.clientIdValue)
1117

@@ -26,6 +32,10 @@ export default class RoomController extends Controller {
2632
})
2733
}
2834

35+
disconnect () {
36+
this.targetObserver.stop()
37+
}
38+
2939
async enter () {
3040
try {
3141
const constraints = { audio: true, video: true }
@@ -46,7 +56,17 @@ export default class RoomController extends Controller {
4656
this.subscription.greet({ to: otherClient.id, from: this.client.id })
4757
}
4858

49-
negotiateConnection ({ detail: { clientId } }) {
59+
remoteMediumTargetConnected (element) {
60+
const clientId = element.id.replace('medium_', '')
61+
this.negotiateConnection(clientId)
62+
}
63+
64+
remoteMediumTargetDisconnected (element) {
65+
const clientId = element.id.replace('medium_', '')
66+
this.teardownClient(clientId)
67+
}
68+
69+
negotiateConnection (clientId) {
5070
const otherClient = this.findOrCreateClient(clientId)
5171

5272
// Be polite to newcomers!
@@ -63,6 +83,11 @@ export default class RoomController extends Controller {
6383
}
6484
}
6585

86+
teardownClient (clientId) {
87+
this.clients[clientId].stop()
88+
delete this.clients[clientId]
89+
}
90+
6691
createNegotiation ({ otherClient, polite }) {
6792
const negotiation = new WebrtcNegotiation({
6893
signaller: this.signaller,
@@ -89,13 +114,6 @@ export default class RoomController extends Controller {
89114
}
90115
}
91116

92-
removeClient ({ from }) {
93-
if (this.clients[from]) {
94-
this.clients[from].stop()
95-
delete this.clients[from]
96-
}
97-
}
98-
99117
findOrCreateClient (id) {
100118
return this.clients[id] || (this.clients[id] = new Client(id))
101119
}
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
{
22
"imports": {
33
"turbo": "<%= asset_path "turbo" %>",
4+
"@stimulus/multimap": "https://cdn.skypack.dev/@stimulus/multimap",
5+
"@stimulus/mutation-observers": "https://cdn.skypack.dev/@stimulus/mutation-observers",
46
<%= importmap_list_with_stimulus_from(
57
"app/assets/javascripts/models",
68
"app/assets/javascripts/controllers",
79
"app/assets/javascripts/subscriptions",
8-
"app/assets/javascripts/libraries"
10+
"app/assets/javascripts/libraries",
11+
"app/assets/javascripts/patches"
912
) %>
1013
}
1114
}

app/assets/javascripts/models/webrtc_negotiation.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export default class WebrtcNegotiation {
5252
this.initiateManualRollback()
5353
this.retryCount++
5454
} else {
55+
this.stop()
5556
console.error(`Negotiation failed after ${this.retryCount} retries`)
5657
}
5758
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { Context } from "stimulus"
2+
import { Multimap } from "@stimulus/multimap"
3+
import { TokenListObserver } from "@stimulus/mutation-observers"
4+
5+
export default class TargetObserver {
6+
constructor(context, delegate) {
7+
this.context = context
8+
this.delegate = delegate
9+
this.targetsByName = new Multimap
10+
}
11+
12+
start() {
13+
if (!this.tokenListObserver) {
14+
this.tokenListObserver = new TokenListObserver(this.element, this.attributeName, this)
15+
this.tokenListObserver.start()
16+
}
17+
}
18+
19+
stop() {
20+
if (this.tokenListObserver) {
21+
this.disconnectAllTargets()
22+
this.tokenListObserver.stop()
23+
delete this.tokenListObserver
24+
}
25+
}
26+
27+
// Token list observer delegate
28+
29+
tokenMatched({ element, content: name }) {
30+
if (this.scope.containsElement(element)) {
31+
this.connectTarget(element, name)
32+
}
33+
}
34+
35+
tokenUnmatched({ element, content: name }) {
36+
this.disconnectTarget(element, name)
37+
}
38+
39+
// Target management
40+
41+
connectTarget(element, name) {
42+
if (!this.targetsByName.has(name, element)) {
43+
this.targetsByName.add(name, element)
44+
this.delegate.targetConnected(element, name)
45+
}
46+
}
47+
48+
disconnectTarget(element, name) {
49+
if (this.targetsByName.has(name, element)) {
50+
this.targetsByName.delete(name, element)
51+
this.delegate.targetDisconnected(element, name)
52+
}
53+
}
54+
55+
disconnectAllTargets() {
56+
for (const name of this.targetsByName.keys) {
57+
for (const element of this.targetsByName.getValuesForKey(name)) {
58+
this.disconnectTarget(element, name)
59+
}
60+
}
61+
}
62+
63+
// Private
64+
65+
get attributeName() {
66+
return `data-${this.context.identifier}-target`
67+
}
68+
69+
get element() {
70+
return this.context.element
71+
}
72+
73+
get scope() {
74+
return this.context.scope
75+
}
76+
}
77+
78+
// Monkey Patch Context
79+
// Target observer delegate
80+
81+
Context.prototype.targetConnected = function (element, name) {
82+
this.invokeControllerMethod(`${name}TargetConnected`, element)
83+
}
84+
85+
Context.prototype.targetDisconnected = function (element, name) {
86+
this.invokeControllerMethod(`${name}TargetDisconnected`, element)
87+
}
88+
89+
// Private
90+
91+
Context.prototype.invokeControllerMethod = function (methodName, ...args) {
92+
const controller = this.controller
93+
if (typeof controller[methodName] == "function") {
94+
controller[methodName](...args)
95+
}
96+
}

0 commit comments

Comments
 (0)