From 01efba3e3f926a10b1f9ae45d954d28f5910feff Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Tue, 20 Oct 2020 19:12:15 +0200 Subject: [PATCH 01/10] Handle websocket --- go.mod | 1 + go.sum | 1 + web/handler.go | 95 ++++------------------------------------ web/web.go | 1 + web/websocket_handler.go | 66 ++++++++++++++++++++++++++++ 5 files changed, 78 insertions(+), 86 deletions(-) create mode 100644 web/websocket_handler.go diff --git a/go.mod b/go.mod index 128eef3..09a0e82 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.13 require ( github.com/go-ldap/ldap/v3 v3.2.3 + github.com/gorilla/websocket v1.4.0 github.com/haivision/srtgo v0.0.0-20200731151239-e00427ae473a github.com/markbates/pkger v0.17.1 github.com/pion/rtp v1.6.0 diff --git a/go.sum b/go.sum index 00c058c..0d16fd8 100644 --- a/go.sum +++ b/go.sum @@ -113,6 +113,7 @@ github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= diff --git a/web/handler.go b/web/handler.go index 5b52bc2..a17191d 100644 --- a/web/handler.go +++ b/web/handler.go @@ -21,76 +21,20 @@ var ( validPath = regexp.MustCompile("^/[a-z0-9@_-]*$") ) -// Handle WebRTC session description exchange via POST -func viewerPostHandler(w http.ResponseWriter, r *http.Request) { - // Limit response body to 128KB - r.Body = http.MaxBytesReader(w, r.Body, 131072) - - // Get stream ID from URL, or from domain name - path := r.URL.Path[1:] - host := r.Host - if strings.Contains(host, ":") { - realHost, _, err := net.SplitHostPort(r.Host) - if err != nil { - log.Printf("Failed to split host and port from %s", r.Host) - return - } - host = realHost - } - host = strings.Replace(host, ".", "-", -1) - if streamID, ok := cfg.MapDomainToStream[host]; ok { - path = streamID - } - - // Decode client description - dec := json.NewDecoder(r.Body) - dec.DisallowUnknownFields() - remoteDescription := webrtc.SessionDescription{} - if err := dec.Decode(&remoteDescription); err != nil { - http.Error(w, "The JSON WebRTC offer is malformed", http.StatusBadRequest) +// Handle site index and viewer pages +func viewerHandler(w http.ResponseWriter, r *http.Request) { + // Validation on path + if validPath.FindStringSubmatch(r.URL.Path) == nil { + http.NotFound(w, r) + log.Printf("Replied not found on %s", r.URL.Path) return } - // Get requested stream - stream, err := streams.Get(path) - if err != nil { - http.Error(w, "Stream not found", http.StatusNotFound) - log.Printf("Stream not found: %s", path) - return + // Check method + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed.", http.StatusMethodNotAllowed) } - // Get requested quality - // FIXME: extract quality from request - qualityName := "source" - q, err := stream.GetQuality(qualityName) - if err != nil { - http.Error(w, "Quality not found", http.StatusNotFound) - log.Printf("Quality not found: %s", qualityName) - return - } - - // Exchange session descriptions with WebRTC stream server - q.WebRtcRemoteSdp <- remoteDescription - localDescription := <-q.WebRtcLocalSdp - - // Send server description as JSON - jsonDesc, err := json.Marshal(localDescription) - if err != nil { - http.Error(w, "An error occurred while formating response", http.StatusInternalServerError) - log.Println("An error occurred while sending session description", err) - return - } - w.Header().Set("Content-Type", "application/json") - _, err = w.Write(jsonDesc) - if err != nil { - log.Println("An error occurred while sending session description", err) - } - - // Increment monitoring - monitoring.WebSessions.Inc() -} - -func viewerGetHandler(w http.ResponseWriter, r *http.Request) { // Get stream ID from URL, or from domain name path := r.URL.Path[1:] host := r.Host @@ -137,27 +81,6 @@ func viewerGetHandler(w http.ResponseWriter, r *http.Request) { monitoring.WebViewerServed.Inc() } -// Handle site index and viewer pages -// POST requests are used to exchange WebRTC session descriptions -func viewerHandler(w http.ResponseWriter, r *http.Request) { - // Validation on path - if validPath.FindStringSubmatch(r.URL.Path) == nil { - http.NotFound(w, r) - log.Printf("Replied not found on %s", r.URL.Path) - return - } - - // Route depending on HTTP method - switch r.Method { - case http.MethodGet: - viewerGetHandler(w, r) - case http.MethodPost: - viewerPostHandler(w, r) - default: - http.Error(w, "Sorry, only GET and POST methods are supported.", http.StatusBadRequest) - } -} - func staticHandler() http.Handler { // Set up static files server staticFs := http.FileServer(pkger.Dir("/web/static")) diff --git a/web/web.go b/web/web.go index 32d1290..44dc90e 100644 --- a/web/web.go +++ b/web/web.go @@ -88,6 +88,7 @@ func Serve(s *messaging.Streams, c *Options) { mux := http.NewServeMux() mux.HandleFunc("/", viewerHandler) mux.Handle("/static/", staticHandler()) + mux.HandleFunc("/_ws/", websocketHandler) mux.HandleFunc("/_stats/", statisticsHandler) log.Printf("HTTP server listening on %s", cfg.ListenAddress) log.Fatal(http.ListenAndServe(cfg.ListenAddress, mux)) diff --git a/web/websocket_handler.go b/web/websocket_handler.go new file mode 100644 index 0000000..2475b7b --- /dev/null +++ b/web/websocket_handler.go @@ -0,0 +1,66 @@ +package web + +import ( + "log" + "net/http" + + "github.com/gorilla/websocket" + "gitlab.crans.org/nounous/ghostream/stream/webrtc" +) + +var upgrader = websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, +} + +// clientDescription is sent by new client +type clientDescription struct { + webRtcSdp webrtc.SessionDescription + stream string + quality string +} + +// websocketHandler exchanges WebRTC SDP and viewer count +func websocketHandler(w http.ResponseWriter, r *http.Request) { + // Upgrade client connection to WebSocket + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("Failed to upgrade client to websocket: %s", err) + return + } + + for { + // Get client description + c := &clientDescription{} + err = conn.ReadJSON(c) + if err != nil { + log.Printf("Failed to receive client description: %s", err) + return + } + + // Get requested stream + stream, err := streams.Get(c.stream) + if err != nil { + log.Printf("Stream not found: %s", c.stream) + return + } + + // Get requested quality + q, err := stream.GetQuality(c.quality) + if err != nil { + log.Printf("Quality not found: %s", c.quality) + return + } + + // Exchange session descriptions with WebRTC stream server + // FIXME: Add trickle ICE support + q.WebRtcRemoteSdp <- c.webRtcSdp + localDescription := <-q.WebRtcLocalSdp + + // Send new local description + if err := conn.WriteJSON(localDescription); err != nil { + log.Println(err) + return + } + } +} From 11231ceb84f82adfc25b49be3eb2599b2db6dcd0 Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Tue, 20 Oct 2020 21:29:41 +0200 Subject: [PATCH 02/10] viewerCounter and websocket JS modules --- .gitignore | 6 +++ web/static/js/main.js | 18 +++++++++ web/static/js/modules/viewerCounter.js | 29 ++++++++++++++ web/static/js/modules/websocket.js | 55 ++++++++++++++++++++++++++ 4 files changed, 108 insertions(+) create mode 100644 web/static/js/main.js create mode 100644 web/static/js/modules/viewerCounter.js create mode 100644 web/static/js/modules/websocket.js diff --git a/.gitignore b/.gitignore index 4b753e2..12b2578 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,9 @@ pkged.go # Profiler and test files *.prof *.test + +# Javascript tools +.eslintrc.js +node_modules +package.json +package-lock.json diff --git a/web/static/js/main.js b/web/static/js/main.js new file mode 100644 index 0000000..1356b41 --- /dev/null +++ b/web/static/js/main.js @@ -0,0 +1,18 @@ +import { GSWebSocket } from "./modules/websocket.js"; +import { ViewerCounter } from "./modules/viewerCounter.js"; + +// Create WebSocket +const s = new GSWebSocket(); +s.open(() => { + // FIXME open callback +}, () => { + // FIXME close callback +}); + +// Create viewer counter +const streamName = "demo"; // FIXME +const viewerCounter = new ViewerCounter( + document.getElementById("connected-people"), + streamName, +); +viewerCounter.regularUpdate(1000); // FIXME diff --git a/web/static/js/modules/viewerCounter.js b/web/static/js/modules/viewerCounter.js new file mode 100644 index 0000000..9c26d29 --- /dev/null +++ b/web/static/js/modules/viewerCounter.js @@ -0,0 +1,29 @@ +/** + * ViewerCounter show the number of active viewers + */ +export class ViewerCounter { + /** + * @param {HTMLElement} element + * @param {String} streamName + */ + constructor(element, streamName) { + this.element = element; + this.url = "/_stats/" + streamName; + } + + /** + * Regulary update counter + * + * @param {Number} updatePeriod + */ + regularUpdate(updatePeriod) { + setInterval(this._refreshViewersCounter, updatePeriod); + } + + _refreshViewersCounter() { + fetch(this.url) + .then(response => response.json()) + .then((data) => this.element.innerText = data.ConnectedViewers) + .catch(console.log); + } +} diff --git a/web/static/js/modules/websocket.js b/web/static/js/modules/websocket.js new file mode 100644 index 0000000..f6a31c7 --- /dev/null +++ b/web/static/js/modules/websocket.js @@ -0,0 +1,55 @@ +/** + * GsWebSocket to do Ghostream signalling + */ +export class GsWebSocket { + constructor() { + const protocol = (window.location.protocol === "https:") ? "wss://" : "ws://"; + this.url = protocol + window.location.host + "/_ws/"; + } + + _open() { + this.socket = new WebSocket(this.url); + } + + /** + * Open websocket. + * + * @param {Function} openCallback Function called when connection is established. + * @param {Function} closeCallback Function called when connection is lost. + */ + open(openCallback, closeCallback) { + this._open(); + this.socket.addEventListener("open", (event) => { + console.log("WebSocket opened"); + openCallback(event); + }); + this.socket.addEventListener("close", (event) => { + console.log("WebSocket closed, retrying connection in 1s..."); + setTimeout(this._open, 1000); + closeCallback(event); + }); + this.socket.addEventListener("error", (event) => { + console.log("WebSocket errored, retrying connection in 1s..."); + setTimeout(this._open, 1000); + closeCallback(event); + }); + } + + /** + * Exchange WebRTC session description with server. + * + * @param {string} data JSON formated data + * @param {Function} receiveCallback Function called when data is received + */ + exchangeDescription(data, receiveCallback) { + if (this.socket.readyState !== 1) { + console.log("WebSocket not ready to send data"); + return; + } + this.socket.send(data); + this.socket.addEventListener("message", (event) => { + console.log("Message from server ", event.data); + receiveCallback(event); + }); + } +} From c88f473ec0e82b5c0334b13e6ccab2f22397b2ac Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Tue, 20 Oct 2020 21:45:26 +0200 Subject: [PATCH 03/10] Remove old JS --- web/static/js/main.js | 61 +++++++++++++++-- web/static/js/modules/websocket.js | 11 ++- web/static/js/sideWidget.js | 12 ---- web/static/js/videoQuality.js | 9 --- web/static/js/viewer.js | 106 ++++++++++++----------------- web/static/js/viewersCounter.js | 12 ---- web/template/player.html | 9 +-- 7 files changed, 106 insertions(+), 114 deletions(-) delete mode 100644 web/static/js/sideWidget.js delete mode 100644 web/static/js/videoQuality.js delete mode 100644 web/static/js/viewersCounter.js diff --git a/web/static/js/main.js b/web/static/js/main.js index 1356b41..e20bc3d 100644 --- a/web/static/js/main.js +++ b/web/static/js/main.js @@ -1,18 +1,65 @@ import { GSWebSocket } from "./modules/websocket.js"; import { ViewerCounter } from "./modules/viewerCounter.js"; +// Some variables that need to be fixed by web page +const ViewersCounterRefreshPeriod = 1000; +const stream = "demo"; +let quality = "source"; + // Create WebSocket const s = new GSWebSocket(); -s.open(() => { - // FIXME open callback -}, () => { - // FIXME close callback +s.open(); + +// Register keyboard events +const viewer = document.getElementById("viewer"); +window.addEventListener("keydown", (event) => { + switch (event.key) { + case "f": + // F key put player in fullscreen + if (document.fullscreenElement !== null) { + document.exitFullscreen(); + } else { + viewer.requestFullscreen(); + } + break; + case "m": + case " ": + // M and space key mute player + viewer.muted = !viewer.muted; + event.preventDefault(); + viewer.play(); + break; + } }); // Create viewer counter -const streamName = "demo"; // FIXME const viewerCounter = new ViewerCounter( document.getElementById("connected-people"), - streamName, + stream, ); -viewerCounter.regularUpdate(1000); // FIXME +viewerCounter.regularUpdate(ViewersCounterRefreshPeriod); + +// Side widget toggler +const sideWidgetToggle = document.getElementById("sideWidgetToggle"); +const sideWidget = document.getElementById("sideWidget"); +if (sideWidgetToggle !== null && sideWidget !== null) { + // On click, toggle side widget visibility + sideWidgetToggle.addEventListener("click", function () { + if (sideWidget.style.display === "none") { + sideWidget.style.display = "block"; + sideWidgetToggle.textContent = "»"; + } else { + sideWidget.style.display = "none"; + sideWidgetToggle.textContent = "«"; + } + }); +} + +// Video quality toggler +document.getElementById("quality").addEventListener("change", (event) => { + quality = event.target.value; + console.log(`Stream quality changed to ${quality}`); + + // Restart the connection with a new quality + // FIXME +}); diff --git a/web/static/js/modules/websocket.js b/web/static/js/modules/websocket.js index f6a31c7..792d9f9 100644 --- a/web/static/js/modules/websocket.js +++ b/web/static/js/modules/websocket.js @@ -17,21 +17,18 @@ export class GsWebSocket { * @param {Function} openCallback Function called when connection is established. * @param {Function} closeCallback Function called when connection is lost. */ - open(openCallback, closeCallback) { + open() { this._open(); - this.socket.addEventListener("open", (event) => { + this.socket.addEventListener("open", () => { console.log("WebSocket opened"); - openCallback(event); }); - this.socket.addEventListener("close", (event) => { + this.socket.addEventListener("close", () => { console.log("WebSocket closed, retrying connection in 1s..."); setTimeout(this._open, 1000); - closeCallback(event); }); - this.socket.addEventListener("error", (event) => { + this.socket.addEventListener("error", () => { console.log("WebSocket errored, retrying connection in 1s..."); setTimeout(this._open, 1000); - closeCallback(event); }); } diff --git a/web/static/js/sideWidget.js b/web/static/js/sideWidget.js deleted file mode 100644 index efc0d9e..0000000 --- a/web/static/js/sideWidget.js +++ /dev/null @@ -1,12 +0,0 @@ -// Side widget toggler -const sideWidgetToggle = document.getElementById("sideWidgetToggle") -sideWidgetToggle.addEventListener("click", function () { - const sideWidget = document.getElementById("sideWidget") - if (sideWidget.style.display === "none") { - sideWidget.style.display = "block" - sideWidgetToggle.textContent = "»" - } else { - sideWidget.style.display = "none" - sideWidgetToggle.textContent = "«" - } -}) \ No newline at end of file diff --git a/web/static/js/videoQuality.js b/web/static/js/videoQuality.js deleted file mode 100644 index ba6e15e..0000000 --- a/web/static/js/videoQuality.js +++ /dev/null @@ -1,9 +0,0 @@ -document.getElementById("quality").addEventListener("change", (event) => { - console.log(`Stream quality changed to ${event.target.value}`) - - // Restart the connection with a new quality - peerConnection.close() - peerConnection = null - streamPath = window.location.href + event.target.value - startPeerConnection() -}) diff --git a/web/static/js/viewer.js b/web/static/js/viewer.js index 9b8f3ea..4891dd8 100644 --- a/web/static/js/viewer.js +++ b/web/static/js/viewer.js @@ -1,97 +1,81 @@ -let peerConnection -let streamPath = window.location.href +let peerConnection; +let streamPath = window.location.href; +let stream = streamPath; +let quality = "source"; -startPeerConnection = () => { + +const startPeerConnection = () => { // Init peer connection peerConnection = new RTCPeerConnection({ iceServers: [{ urls: stunServers }] - }) + }); // On connection change, change indicator color // if connection failed, restart peer connection peerConnection.oniceconnectionstatechange = e => { - console.log("ICE connection state changed, " + peerConnection.iceConnectionState) + console.log("ICE connection state changed, " + peerConnection.iceConnectionState); switch (peerConnection.iceConnectionState) { - case "disconnected": - document.getElementById("connectionIndicator").style.fill = "#dc3545" - break - case "checking": - document.getElementById("connectionIndicator").style.fill = "#ffc107" - break - case "connected": - document.getElementById("connectionIndicator").style.fill = "#28a745" - break - case "closed": - case "failed": - console.log("Connection failed, restarting...") - peerConnection.close() - peerConnection = null - setTimeout(startPeerConnection, 1000) - break + case "disconnected": + document.getElementById("connectionIndicator").style.fill = "#dc3545"; + break; + case "checking": + document.getElementById("connectionIndicator").style.fill = "#ffc107"; + break; + case "connected": + document.getElementById("connectionIndicator").style.fill = "#28a745"; + break; + case "closed": + case "failed": + console.log("Connection failed, restarting..."); + peerConnection.close(); + peerConnection = null; + setTimeout(startPeerConnection, 1000); + break; } - } + }; // We want to receive audio and video - peerConnection.addTransceiver('video', { 'direction': 'sendrecv' }) - peerConnection.addTransceiver('audio', { 'direction': 'sendrecv' }) + peerConnection.addTransceiver("video", { "direction": "sendrecv" }); + peerConnection.addTransceiver("audio", { "direction": "sendrecv" }); // Create offer and set local description peerConnection.createOffer().then(offer => { // After setLocalDescription, the browser will fire onicecandidate events - peerConnection.setLocalDescription(offer) - }).catch(console.log) + peerConnection.setLocalDescription(offer); + }).catch(console.log); // When candidate is null, ICE layer has run out of potential configurations to suggest // so let's send the offer to the server peerConnection.onicecandidate = event => { if (event.candidate === null) { // Send offer to server - // The server know the stream name from the url // The server replies with its description // After setRemoteDescription, the browser will fire ontrack events - console.log("Sending session description to server") + console.log("Sending session description to server"); fetch(streamPath, { - method: 'POST', + method: "POST", headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' + "Accept": "application/json", + "Content-Type": "application/json" }, - body: JSON.stringify(peerConnection.localDescription) + body: JSON.stringify({ + "webRtcSdp": peerConnection.localDescription, + "stream": stream, + "quality": quality + }) }) .then(response => response.json()) .then((data) => peerConnection.setRemoteDescription(new RTCSessionDescription(data))) - .catch(console.log) + .catch(console.log); } - } + }; // When video track is received, configure player peerConnection.ontrack = function (event) { - console.log(`New ${event.track.kind} track`) + console.log(`New ${event.track.kind} track`); if (event.track.kind === "video") { - const viewer = document.getElementById('viewer') - viewer.srcObject = event.streams[0] + const viewer = document.getElementById("viewer"); + viewer.srcObject = event.streams[0]; } - } -} - -// Register keyboard events -let viewer = document.getElementById("viewer") -window.addEventListener("keydown", (event) => { - switch (event.key) { - case 'f': - // F key put player in fullscreen - if (document.fullscreenElement !== null) { - document.exitFullscreen() - } else { - viewer.requestFullscreen() - } - break - case 'm': - case ' ': - // M and space key mute player - viewer.muted = !viewer.muted - event.preventDefault() - viewer.play() - break - } -}) + }; +}; diff --git a/web/static/js/viewersCounter.js b/web/static/js/viewersCounter.js deleted file mode 100644 index 7298f6e..0000000 --- a/web/static/js/viewersCounter.js +++ /dev/null @@ -1,12 +0,0 @@ -// Refresh viewer count by pulling metric from server -function refreshViewersCounter(streamID, period) { - // Distinguish oneDomainPerStream mode - fetch("/_stats/" + streamID) - .then(response => response.json()) - .then((data) => document.getElementById("connected-people").innerText = data.ConnectedViewers) - .catch(console.log) - - setTimeout(() => { - refreshViewersCounter(streamID, period) - }, period) -} diff --git a/web/template/player.html b/web/template/player.html index 3e340a4..20d6125 100644 --- a/web/template/player.html +++ b/web/template/player.html @@ -34,14 +34,11 @@ {{end}} -{{if .WidgetURL}}{{end}} - - - + -{{end}} \ No newline at end of file +{{end}} From 0b3fb87fa2006d5603c701e18ce342f73d4da790 Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Tue, 20 Oct 2020 21:59:07 +0200 Subject: [PATCH 04/10] Working javascript modules --- web/static/js/main.js | 122 +++++++++++++++++++++------------------ web/template/player.html | 15 +++-- 2 files changed, 73 insertions(+), 64 deletions(-) diff --git a/web/static/js/main.js b/web/static/js/main.js index e20bc3d..365731a 100644 --- a/web/static/js/main.js +++ b/web/static/js/main.js @@ -1,65 +1,75 @@ -import { GSWebSocket } from "./modules/websocket.js"; +import { GsWebSocket } from "./modules/websocket.js"; import { ViewerCounter } from "./modules/viewerCounter.js"; -// Some variables that need to be fixed by web page -const ViewersCounterRefreshPeriod = 1000; -const stream = "demo"; -let quality = "source"; +/** + * Initialize viewer page + * + * @param {String} stream + * @param {List} stunServers + * @param {Number} viewersCounterRefreshPeriod + */ +export function initViewerPage(stream, stunServers, viewersCounterRefreshPeriod) { + // Default quality + let quality = "source"; -// Create WebSocket -const s = new GSWebSocket(); -s.open(); + // Create WebSocket + const s = new GsWebSocket(); + s.open(); -// Register keyboard events -const viewer = document.getElementById("viewer"); -window.addEventListener("keydown", (event) => { - switch (event.key) { - case "f": - // F key put player in fullscreen - if (document.fullscreenElement !== null) { - document.exitFullscreen(); - } else { - viewer.requestFullscreen(); + // Create WebRTC + // FIXME startPeerConnection() with stunServers + + // Register keyboard events + const viewer = document.getElementById("viewer"); + window.addEventListener("keydown", (event) => { + switch (event.key) { + case "f": + // F key put player in fullscreen + if (document.fullscreenElement !== null) { + document.exitFullscreen(); + } else { + viewer.requestFullscreen(); + } + break; + case "m": + case " ": + // M and space key mute player + viewer.muted = !viewer.muted; + event.preventDefault(); + viewer.play(); + break; } - break; - case "m": - case " ": - // M and space key mute player - viewer.muted = !viewer.muted; - event.preventDefault(); - viewer.play(); - break; + }); + + // Create viewer counter + const viewerCounter = new ViewerCounter( + document.getElementById("connected-people"), + stream, + ); + viewerCounter.regularUpdate(viewersCounterRefreshPeriod); + + // Side widget toggler + const sideWidgetToggle = document.getElementById("sideWidgetToggle"); + const sideWidget = document.getElementById("sideWidget"); + if (sideWidgetToggle !== null && sideWidget !== null) { + // On click, toggle side widget visibility + sideWidgetToggle.addEventListener("click", function () { + if (sideWidget.style.display === "none") { + sideWidget.style.display = "block"; + sideWidgetToggle.textContent = "»"; + } else { + sideWidget.style.display = "none"; + sideWidgetToggle.textContent = "«"; + } + }); } -}); -// Create viewer counter -const viewerCounter = new ViewerCounter( - document.getElementById("connected-people"), - stream, -); -viewerCounter.regularUpdate(ViewersCounterRefreshPeriod); + // Video quality toggler + document.getElementById("quality").addEventListener("change", (event) => { + quality = event.target.value; + console.log(`Stream quality changed to ${quality}`); -// Side widget toggler -const sideWidgetToggle = document.getElementById("sideWidgetToggle"); -const sideWidget = document.getElementById("sideWidget"); -if (sideWidgetToggle !== null && sideWidget !== null) { - // On click, toggle side widget visibility - sideWidgetToggle.addEventListener("click", function () { - if (sideWidget.style.display === "none") { - sideWidget.style.display = "block"; - sideWidgetToggle.textContent = "»"; - } else { - sideWidget.style.display = "none"; - sideWidgetToggle.textContent = "«"; - } - }); + // Restart the connection with a new quality + // FIXME + }); } - -// Video quality toggler -document.getElementById("quality").addEventListener("change", (event) => { - quality = event.target.value; - console.log(`Stream quality changed to ${quality}`); - - // Restart the connection with a new quality - // FIXME -}); diff --git a/web/template/player.html b/web/template/player.html index 20d6125..61cd7b1 100644 --- a/web/template/player.html +++ b/web/template/player.html @@ -34,18 +34,17 @@ {{end}} - - {{end}} From 9d162b13edad5b891efda1e685f1706fd14271a8 Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Wed, 21 Oct 2020 22:10:39 +0200 Subject: [PATCH 05/10] WebRTC JS module --- web/static/js/main.js | 13 +++- web/static/js/modules/webrtc.js | 98 ++++++++++++++++++++++++++++++ web/static/js/modules/websocket.js | 25 +++++--- web/static/js/viewer.js | 81 ------------------------ 4 files changed, 128 insertions(+), 89 deletions(-) create mode 100644 web/static/js/modules/webrtc.js delete mode 100644 web/static/js/viewer.js diff --git a/web/static/js/main.js b/web/static/js/main.js index 365731a..3e7ec0b 100644 --- a/web/static/js/main.js +++ b/web/static/js/main.js @@ -1,5 +1,6 @@ import { GsWebSocket } from "./modules/websocket.js"; import { ViewerCounter } from "./modules/viewerCounter.js"; +import { GsWebRTC } from "./modules/webrtc.js"; /** * Initialize viewer page @@ -17,7 +18,17 @@ export function initViewerPage(stream, stunServers, viewersCounterRefreshPeriod) s.open(); // Create WebRTC - // FIXME startPeerConnection() with stunServers + const c = new GsWebRTC( + stunServers, + document.getElementById("connectionIndicator"), + ); + c.createOffer(); + c.onICECandidate(localDescription => { + s.sendDescription(localDescription, stream, quality); + }); + s.onDescription(data => { + c.setRemoteDescription(data); + }); // Register keyboard events const viewer = document.getElementById("viewer"); diff --git a/web/static/js/modules/webrtc.js b/web/static/js/modules/webrtc.js new file mode 100644 index 0000000..b6e5366 --- /dev/null +++ b/web/static/js/modules/webrtc.js @@ -0,0 +1,98 @@ +/** + * GsWebRTC to connect to Ghostream + */ +export class GsWebRTC { + /** + * @param {list} stunServers + * @param {HTMLElement} connectionIndicator + */ + constructor(stunServers, connectionIndicator) { + this.connectionIndicator = connectionIndicator; + this.pc = new RTCPeerConnection({ + iceServers: [{ urls: stunServers }] + }); + + // We want to receive audio and video + this.pc.addTransceiver("video", { "direction": "sendrecv" }); + this.pc.addTransceiver("audio", { "direction": "sendrecv" }); + + // Configure events + this.pc.oniceconnectionstatechange = this._onConnectionStateChange; + this.pc.ontrack = this._onTrack; + } + + /** + * On connection change, log it and change indicator. + * If connection closed or failed, try to reconnect. + */ + _onConnectionStateChange() { + console.log("ICE connection state changed to " + this.pc.iceConnectionState); + switch (this.pc.iceConnectionState) { + case "disconnected": + this.connectionIndicator.style.fill = "#dc3545"; + break; + case "checking": + this.connectionIndicator.style.fill = "#ffc107"; + break; + case "connected": + this.connectionIndicator.style.fill = "#28a745"; + break; + case "closed": + case "failed": + console.log("Connection closed, restarting..."); + /*peerConnection.close(); + peerConnection = null; + setTimeout(startPeerConnection, 1000);*/ + break; + } + } + + /** + * On new track, add it to the player + * @param {Event} event + */ + _onTrack(event) { + console.log(`New ${event.track.kind} track`); + if (event.track.kind === "video") { + const viewer = document.getElementById("viewer"); + viewer.srcObject = event.streams[0]; + } + } + + /** + * Create an offer and set local description. + * After that the browser will fire onicecandidate events. + */ + createOffer() { + this.pc.createOffer().then(offer => { + this.pc.setLocalDescription(offer); + console.log("WebRTC offer created"); + }).catch(console.log); + } + + /** + * Register a function to call to send local descriptions + * @param {Function} sendFunction Called with a local description to send. + */ + onICECandidate(sendFunction) { + // When candidate is null, ICE layer has run out of potential configurations to suggest + // so let's send the offer to the server. + // FIXME: Send offers progressively to do Trickle ICE + this.pc.onicecandidate = event => { + if (event.candidate === null) { + // Send offer to server + console.log("Sending session description to server"); + sendFunction(this.pc.localDescription); + } + }; + } + + /** + * Set WebRTC remote description + * After that, the connection will be established and ontrack will be fired. + * @param {*} data Session description data + */ + setRemoteDescription(data) { + this.pc.setRemoteDescription(new RTCSessionDescription(data)); + } +} diff --git a/web/static/js/modules/websocket.js b/web/static/js/modules/websocket.js index 792d9f9..940cc96 100644 --- a/web/static/js/modules/websocket.js +++ b/web/static/js/modules/websocket.js @@ -13,7 +13,6 @@ export class GsWebSocket { /** * Open websocket. - * * @param {Function} openCallback Function called when connection is established. * @param {Function} closeCallback Function called when connection is lost. */ @@ -34,19 +33,31 @@ export class GsWebSocket { /** * Exchange WebRTC session description with server. - * - * @param {string} data JSON formated data - * @param {Function} receiveCallback Function called when data is received + * @param {SessionDescription} localDescription WebRTC local SDP + * @param {string} stream Name of the stream + * @param {string} quality Requested quality */ - exchangeDescription(data, receiveCallback) { + sendDescription(localDescription, stream, quality) { if (this.socket.readyState !== 1) { console.log("WebSocket not ready to send data"); return; } - this.socket.send(data); + this.socket.send(JSON.stringify({ + "webRtcSdp": localDescription, + "stream": stream, + "quality": quality + })); + } + + /** + * Set callback function on new session description. + * @param {Function} callback Function called when data is received + */ + onDescription(callback) { this.socket.addEventListener("message", (event) => { + // FIXME: json to session description console.log("Message from server ", event.data); - receiveCallback(event); + callback(event.data); }); } } diff --git a/web/static/js/viewer.js b/web/static/js/viewer.js deleted file mode 100644 index 4891dd8..0000000 --- a/web/static/js/viewer.js +++ /dev/null @@ -1,81 +0,0 @@ -let peerConnection; -let streamPath = window.location.href; -let stream = streamPath; -let quality = "source"; - - -const startPeerConnection = () => { - // Init peer connection - peerConnection = new RTCPeerConnection({ - iceServers: [{ urls: stunServers }] - }); - - // On connection change, change indicator color - // if connection failed, restart peer connection - peerConnection.oniceconnectionstatechange = e => { - console.log("ICE connection state changed, " + peerConnection.iceConnectionState); - switch (peerConnection.iceConnectionState) { - case "disconnected": - document.getElementById("connectionIndicator").style.fill = "#dc3545"; - break; - case "checking": - document.getElementById("connectionIndicator").style.fill = "#ffc107"; - break; - case "connected": - document.getElementById("connectionIndicator").style.fill = "#28a745"; - break; - case "closed": - case "failed": - console.log("Connection failed, restarting..."); - peerConnection.close(); - peerConnection = null; - setTimeout(startPeerConnection, 1000); - break; - } - }; - - // We want to receive audio and video - peerConnection.addTransceiver("video", { "direction": "sendrecv" }); - peerConnection.addTransceiver("audio", { "direction": "sendrecv" }); - - // Create offer and set local description - peerConnection.createOffer().then(offer => { - // After setLocalDescription, the browser will fire onicecandidate events - peerConnection.setLocalDescription(offer); - }).catch(console.log); - - // When candidate is null, ICE layer has run out of potential configurations to suggest - // so let's send the offer to the server - peerConnection.onicecandidate = event => { - if (event.candidate === null) { - // Send offer to server - // The server replies with its description - // After setRemoteDescription, the browser will fire ontrack events - console.log("Sending session description to server"); - fetch(streamPath, { - method: "POST", - headers: { - "Accept": "application/json", - "Content-Type": "application/json" - }, - body: JSON.stringify({ - "webRtcSdp": peerConnection.localDescription, - "stream": stream, - "quality": quality - }) - }) - .then(response => response.json()) - .then((data) => peerConnection.setRemoteDescription(new RTCSessionDescription(data))) - .catch(console.log); - } - }; - - // When video track is received, configure player - peerConnection.ontrack = function (event) { - console.log(`New ${event.track.kind} track`); - if (event.track.kind === "video") { - const viewer = document.getElementById("viewer"); - viewer.srcObject = event.streams[0]; - } - }; -}; From e461c0b526d60848392bf84c00642eb1c01d9258 Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Wed, 21 Oct 2020 22:38:36 +0200 Subject: [PATCH 06/10] Fix some undefined this in js classes --- web/static/js/main.js | 1 + web/static/js/modules/viewerCounter.js | 4 ++-- web/static/js/modules/webrtc.js | 4 ++-- web/static/js/modules/websocket.js | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/web/static/js/main.js b/web/static/js/main.js index 3e7ec0b..48b3020 100644 --- a/web/static/js/main.js +++ b/web/static/js/main.js @@ -58,6 +58,7 @@ export function initViewerPage(stream, stunServers, viewersCounterRefreshPeriod) stream, ); viewerCounter.regularUpdate(viewersCounterRefreshPeriod); + viewerCounter.refreshViewersCounter(); // Side widget toggler const sideWidgetToggle = document.getElementById("sideWidgetToggle"); diff --git a/web/static/js/modules/viewerCounter.js b/web/static/js/modules/viewerCounter.js index 9c26d29..b95bf38 100644 --- a/web/static/js/modules/viewerCounter.js +++ b/web/static/js/modules/viewerCounter.js @@ -17,10 +17,10 @@ export class ViewerCounter { * @param {Number} updatePeriod */ regularUpdate(updatePeriod) { - setInterval(this._refreshViewersCounter, updatePeriod); + setInterval(() => this.refreshViewersCounter(), updatePeriod); } - _refreshViewersCounter() { + refreshViewersCounter() { fetch(this.url) .then(response => response.json()) .then((data) => this.element.innerText = data.ConnectedViewers) diff --git a/web/static/js/modules/webrtc.js b/web/static/js/modules/webrtc.js index b6e5366..fea8037 100644 --- a/web/static/js/modules/webrtc.js +++ b/web/static/js/modules/webrtc.js @@ -17,8 +17,8 @@ export class GsWebRTC { this.pc.addTransceiver("audio", { "direction": "sendrecv" }); // Configure events - this.pc.oniceconnectionstatechange = this._onConnectionStateChange; - this.pc.ontrack = this._onTrack; + this.pc.oniceconnectionstatechange = () => this._onConnectionStateChange(); + this.pc.ontrack = (e) => this._onTrack(e); } /** diff --git a/web/static/js/modules/websocket.js b/web/static/js/modules/websocket.js index 940cc96..0f33a84 100644 --- a/web/static/js/modules/websocket.js +++ b/web/static/js/modules/websocket.js @@ -23,11 +23,11 @@ export class GsWebSocket { }); this.socket.addEventListener("close", () => { console.log("WebSocket closed, retrying connection in 1s..."); - setTimeout(this._open, 1000); + setTimeout(() => this._open(), 1000); }); this.socket.addEventListener("error", () => { console.log("WebSocket errored, retrying connection in 1s..."); - setTimeout(this._open, 1000); + setTimeout(() => this._open(), 1000); }); } From 2928e8ae77e097e6830c9d722977e9d76883b576 Mon Sep 17 00:00:00 2001 From: Alexandre Iooss Date: Wed, 21 Oct 2020 22:43:01 +0200 Subject: [PATCH 07/10] Rename main.js to viewer.js --- web/static/js/{main.js => viewer.js} | 0 web/template/player.html | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename web/static/js/{main.js => viewer.js} (100%) diff --git a/web/static/js/main.js b/web/static/js/viewer.js similarity index 100% rename from web/static/js/main.js rename to web/static/js/viewer.js diff --git a/web/template/player.html b/web/template/player.html index 61cd7b1..e5efc69 100644 --- a/web/template/player.html +++ b/web/template/player.html @@ -35,7 +35,7 @@