├── settings.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── src ├── main │ ├── resources │ │ ├── static │ │ │ ├── css │ │ │ │ └── main.css │ │ │ └── js │ │ │ │ ├── main.js │ │ │ │ ├── sdp_offer.js │ │ │ │ ├── streaming.js │ │ │ │ ├── old_client.js │ │ │ │ └── webrtc_client.js │ │ ├── application.yml │ │ └── templates │ │ │ ├── streaming.html │ │ │ ├── sdp_offer.html │ │ │ ├── main.html │ │ │ └── chat_room.html │ └── java │ │ └── io │ │ └── github │ │ └── benkoff │ │ └── webrtcss │ │ ├── Application.java │ │ ├── service │ │ ├── MainService.java │ │ └── MainServiceImpl.java │ │ ├── util │ │ └── Parser.java │ │ ├── domain │ │ ├── Room.java │ │ ├── RoomService.java │ │ └── WebSocketMessage.java │ │ ├── config │ │ └── WebSocketConfig.java │ │ ├── controller │ │ └── MainController.java │ │ └── socket │ │ └── SignalHandler.java └── test │ └── java │ └── io │ └── github │ └── benkoff │ └── webrtcss │ ├── ApplicationTests.java │ ├── service │ └── RoomServiceTest.java │ ├── socket │ └── SignalHandlerTest.java │ └── controller │ └── MainControllerTest.java ├── Dockerfile ├── .gitignore ├── gradlew.bat ├── README.md └── gradlew /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'webrtcss' 2 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Benkoff/WebRTC-SS/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/main/resources/static/css/main.css: -------------------------------------------------------------------------------- 1 | video { 2 | max-width: 100%; 3 | } 4 | 5 | textarea { 6 | width: 100%; 7 | height: 100%; 8 | } -------------------------------------------------------------------------------- /src/main/resources/application.yml: -------------------------------------------------------------------------------- 1 | ##THYMELEAF DEVELOP 2 | spring: 3 | thymeleaf: 4 | cache: false 5 | 6 | logging: 7 | level: 8 | org.springframework: info 9 | io.github.benkoff.webrtcss: debug 10 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Tue Sep 25 22:50:27 EEST 2018 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-4.8.1-all.zip 7 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM frolvlad/alpine-oraclejdk8:slim 2 | VOLUME /tmp 3 | ARG DEPENDENCY 4 | COPY ${DEPENDENCY}/BOOT-INF/lib /app/lib 5 | COPY ${DEPENDENCY}/META-INF /app/META-INF 6 | COPY ${DEPENDENCY}/BOOT-INF/classes /app 7 | ENTRYPOINT ["java","-cp","app:app/lib/*","io.github.benkoff.webrtcss.Application"] -------------------------------------------------------------------------------- /src/main/java/io/github/benkoff/webrtcss/Application.java: -------------------------------------------------------------------------------- 1 | package io.github.benkoff.webrtcss; 2 | 3 | import org.springframework.boot.SpringApplication; 4 | import org.springframework.boot.autoconfigure.SpringBootApplication; 5 | import org.springframework.web.socket.config.annotation.EnableWebSocket; 6 | 7 | @SpringBootApplication 8 | @EnableWebSocket 9 | public class Application { 10 | 11 | public static void main(String[] args) { 12 | SpringApplication.run(Application.class, args); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/test/java/io/github/benkoff/webrtcss/ApplicationTests.java: -------------------------------------------------------------------------------- 1 | package io.github.benkoff.webrtcss; 2 | 3 | import org.junit.Test; 4 | import org.junit.runner.RunWith; 5 | import org.springframework.boot.test.context.SpringBootTest; 6 | import org.springframework.test.context.junit4.SpringRunner; 7 | import org.springframework.test.context.web.WebAppConfiguration; 8 | 9 | @RunWith(SpringRunner.class) 10 | @SpringBootTest 11 | @WebAppConfiguration 12 | public class ApplicationTests { 13 | 14 | @Test 15 | public void contextLoads() { 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/main/java/io/github/benkoff/webrtcss/service/MainService.java: -------------------------------------------------------------------------------- 1 | package io.github.benkoff.webrtcss.service; 2 | 3 | import org.springframework.validation.BindingResult; 4 | import org.springframework.web.servlet.ModelAndView; 5 | 6 | public interface MainService { 7 | ModelAndView displayMainPage(Long id, String uuid); 8 | ModelAndView processRoomSelection(String sid, String uuid, BindingResult bindingResult); 9 | ModelAndView displaySelectedRoom(String sid, String uuid); 10 | ModelAndView processRoomExit(String sid, String uuid); 11 | ModelAndView requestRandomRoomNumber(String uuid); 12 | } 13 | -------------------------------------------------------------------------------- /src/main/java/io/github/benkoff/webrtcss/util/Parser.java: -------------------------------------------------------------------------------- 1 | package io.github.benkoff.webrtcss.util; 2 | 3 | import org.slf4j.Logger; 4 | import org.slf4j.LoggerFactory; 5 | import org.springframework.stereotype.Service; 6 | 7 | import java.util.Optional; 8 | 9 | @Service 10 | public class Parser { 11 | private final Logger logger = LoggerFactory.getLogger(this.getClass()); 12 | 13 | public Optional parseId(String sid) { 14 | Long id = null; 15 | try { 16 | id = Long.valueOf(sid); 17 | } catch (Exception e) { 18 | logger.debug("An error occured: {}", e.getMessage()); 19 | } 20 | 21 | return Optional.ofNullable(id); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/resources/static/js/main.js: -------------------------------------------------------------------------------- 1 | $(function(){ 2 | const uuidInput = document.querySelector('input#uuid'); 3 | 4 | function guid() { 5 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 6 | let r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8); 7 | return v.toString(16); 8 | }); 9 | } 10 | 11 | if (localStorage.getItem("uuid") === null) { 12 | localStorage.setItem("uuid", guid()); 13 | } 14 | uuidInput.value = localStorage.getItem("uuid"); 15 | console.log("local.uuid:" + localStorage.getItem("uuid")); 16 | // console.log("input.value:" + uuidInput.value); 17 | }); 18 | 19 | function addUuidToButtonLink(button) { 20 | let id = 'button-link-' + button.value; 21 | let ref = document.getElementById(id).href; 22 | document.getElementById(id).href = ref + '/user/' + localStorage.getItem("uuid"); 23 | console.log("link.href:" + document.getElementById(id).href); 24 | } 25 | -------------------------------------------------------------------------------- /src/test/java/io/github/benkoff/webrtcss/service/RoomServiceTest.java: -------------------------------------------------------------------------------- 1 | package io.github.benkoff.webrtcss.service; 2 | 3 | import io.github.benkoff.webrtcss.domain.Room; 4 | import io.github.benkoff.webrtcss.domain.RoomService; 5 | import org.junit.Test; 6 | import org.junit.runner.RunWith; 7 | import org.springframework.beans.factory.annotation.Autowired; 8 | import org.springframework.boot.test.context.SpringBootTest; 9 | import org.springframework.test.context.junit4.SpringRunner; 10 | import org.springframework.test.context.web.WebAppConfiguration; 11 | 12 | import static org.assertj.core.api.Assertions.assertThat; 13 | 14 | @RunWith(SpringRunner.class) 15 | @SpringBootTest 16 | @WebAppConfiguration 17 | public class RoomServiceTest { 18 | @Autowired 19 | private RoomService service; 20 | 21 | @Test 22 | public void shouldReturnRoom_whenFindRoomByStringId() { 23 | Room room = new Room(1L); 24 | service.addRoom(room); 25 | Room actualRoom = service.findRoomByStringId(Long.valueOf(1L).toString()).get(); 26 | 27 | assertThat(actualRoom) 28 | .isNotNull() 29 | .isEqualToComparingFieldByFieldRecursively(room); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/java/io/github/benkoff/webrtcss/domain/Room.java: -------------------------------------------------------------------------------- 1 | package io.github.benkoff.webrtcss.domain; 2 | 3 | import org.springframework.web.socket.WebSocketSession; 4 | 5 | import javax.validation.constraints.NotNull; 6 | import java.util.HashMap; 7 | import java.util.Map; 8 | import java.util.Objects; 9 | 10 | public class Room { 11 | @NotNull private final Long id; 12 | // sockets by user names 13 | private final Map clients = new HashMap<>(); 14 | 15 | public Room(Long id) { 16 | this.id = id; 17 | } 18 | 19 | public Long getId() { 20 | return id; 21 | } 22 | 23 | Map getClients() { 24 | return clients; 25 | } 26 | 27 | @Override 28 | public boolean equals(final Object o) { 29 | if (this == o) return true; 30 | if (o == null || getClass() != o.getClass()) return false; 31 | final Room room = (Room) o; 32 | return Objects.equals(getId(), room.getId()) && 33 | Objects.equals(getClients(), room.getClients()); 34 | } 35 | 36 | @Override 37 | public int hashCode() { 38 | 39 | return Objects.hash(getId(), getClients()); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/io/github/benkoff/webrtcss/config/WebSocketConfig.java: -------------------------------------------------------------------------------- 1 | package io.github.benkoff.webrtcss.config; 2 | 3 | import io.github.benkoff.webrtcss.socket.SignalHandler; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.web.socket.WebSocketHandler; 7 | import org.springframework.web.socket.config.annotation.WebSocketConfigurer; 8 | import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry; 9 | import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean; 10 | 11 | @Configuration 12 | public class WebSocketConfig implements WebSocketConfigurer { 13 | 14 | @Override 15 | public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { 16 | registry.addHandler(signalHandler(), "/signal") 17 | .setAllowedOrigins("*"); // allow all origins 18 | } 19 | 20 | @Bean 21 | public WebSocketHandler signalHandler() { 22 | return new SignalHandler(); 23 | } 24 | 25 | @Bean 26 | public ServletServerContainerFactoryBean createWebSocketContainer() { 27 | ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean(); 28 | container.setMaxTextMessageBufferSize(8192); 29 | container.setMaxBinaryMessageBufferSize(8192); 30 | return container; 31 | } 32 | } -------------------------------------------------------------------------------- /src/main/resources/templates/streaming.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Sample Streaming 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |

Simple WebRTC Signalling Server

19 |
20 |
21 | 22 | 23 | 24 |
25 |
26 |
27 | 28 |
29 |
30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 2 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 3 | 4 | # My own Special offer 5 | .idea 6 | 7 | *.iml 8 | 9 | /target/ 10 | !.mvn/wrapper/maven-wrapper.jar 11 | 12 | # User-specific stuff 13 | .idea/**/workspace.xml 14 | .idea/**/tasks.xml 15 | .idea/dictionaries 16 | 17 | # Sensitive or high-churn files 18 | .idea/**/dataSources/ 19 | .idea/**/dataSources.ids 20 | .idea/**/dataSources.local.xml 21 | .idea/**/sqlDataSources.xml 22 | .idea/**/dynamic.xml 23 | .idea/**/uiDesigner.xml 24 | .idea/**/gradle.xml 25 | .idea/**/libraries 26 | 27 | # Gradle 28 | .gradle 29 | /build/ 30 | 31 | # Ignore Gradle GUI config 32 | gradle-app.setting 33 | 34 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 35 | !gradle-wrapper.jar 36 | 37 | # Cache of project 38 | .gradletasknamecache 39 | 40 | # CMake 41 | cmake-build-debug/ 42 | cmake-build-release/ 43 | 44 | # Mongo Explorer plugin 45 | .idea/**/mongoSettings.xml 46 | 47 | # File-based project format 48 | *.iws 49 | 50 | # IntelliJ 51 | out/ 52 | 53 | # mpeltonen/sbt-idea plugin 54 | .idea_modules/ 55 | 56 | # JIRA plugin 57 | atlassian-ide-plugin.xml 58 | 59 | # Cursive Clojure plugin 60 | .idea/replstate.xml 61 | 62 | # Crashlytics plugin (for Android Studio and IntelliJ) 63 | com_crashlytics_export_strings.xml 64 | crashlytics.properties 65 | crashlytics-build.properties 66 | fabric.properties 67 | 68 | # Editor-based Rest Client 69 | .idea/httpRequests 70 | *.log 71 | -------------------------------------------------------------------------------- /src/main/resources/static/js/sdp_offer.js: -------------------------------------------------------------------------------- 1 | //based on https://github.com/webrtc/samples/blob/gh-pages/src/content/peerconnection/create-offer/js/main.js 2 | 3 | 'use strict'; 4 | 5 | const audioInput = document.querySelector('input#audio'); 6 | const restartInput = document.querySelector('input#restart'); 7 | const vadInput = document.querySelector('input#vad'); 8 | const videoInput = document.querySelector('input#video'); 9 | 10 | const outputTextarea = document.querySelector('textarea#output'); 11 | const createOfferButton = document.querySelector('button#createOffer'); 12 | 13 | createOfferButton.addEventListener('click', createOffer); 14 | 15 | async function createOffer() { 16 | outputTextarea.value = ''; 17 | const peerConnection = new RTCPeerConnection(null); 18 | const acx = new AudioContext(); 19 | const dst = acx.createMediaStreamDestination(); 20 | 21 | const offerOptions = { 22 | // New spec states offerToReceiveAudio/Video are of type long (due to 23 | // having to tell how many "m" lines to generate). 24 | // http://w3c.github.io/webrtc-pc/#idl-def-RTCOfferAnswerOptions. 25 | offerToReceiveAudio: (audioInput.checked) ? 1 : 0, 26 | offerToReceiveVideo: (videoInput.checked) ? 1 : 0, 27 | voiceActivityDetection: vadInput.checked, 28 | iceRestart: restartInput.checked 29 | }; 30 | 31 | try { 32 | const offer = await peerConnection.createOffer(offerOptions); 33 | // peerConnection.setLocalDescription(offer); 34 | outputTextarea.value = offer.sdp; 35 | } catch (e) { 36 | outputTextarea.value = `Failed to create offer: ${e}`; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/resources/static/js/streaming.js: -------------------------------------------------------------------------------- 1 | //based on client from https://codelabs.developers.google.com/codelabs/webrtc-web/#3 2 | 'use strict'; 3 | 4 | const vgaButton = document.querySelector('#vga'); 5 | const qvgaButton = document.querySelector('#qvga'); 6 | const hdButton = document.querySelector('#hd'); 7 | // Video element where stream will be placed. 8 | const localVideo = document.querySelector('video'); 9 | // Local stream that will be reproduced on the video. 10 | let localStream; 11 | // media constraints 12 | const qvgaConstraints = { 13 | video: {width: {exact: 320}, height: {exact: 240}}, 14 | audio: true 15 | }; 16 | const vgaConstraints = { 17 | video: {width: {exact: 640}, height: {exact: 480}}, 18 | audio: true 19 | }; 20 | const hdConstraints = { 21 | video: {width: {exact: 1280}, height: {exact: 720}}, 22 | audio: true 23 | }; 24 | 25 | // Get Media with selected constraints 26 | qvgaButton.onclick = () => { 27 | getMedia(qvgaConstraints); 28 | }; 29 | 30 | vgaButton.onclick = () => { 31 | getMedia(vgaConstraints); 32 | }; 33 | 34 | hdButton.onclick = () => { 35 | getMedia(hdConstraints); 36 | }; 37 | 38 | // Initializes media stream. 39 | function getMedia(constraints) { 40 | if (localStream) { 41 | localStream.getTracks().forEach(track => { 42 | track.stop(); 43 | }); 44 | } 45 | navigator.mediaDevices.getUserMedia(constraints) 46 | .then(getLocalMediaStream).catch(handleLocalMediaStreamError); 47 | } 48 | 49 | // Handles success by adding the MediaStream to the video element. 50 | function getLocalMediaStream(mediaStream) { 51 | localStream = mediaStream; 52 | localVideo.srcObject = mediaStream; 53 | } 54 | 55 | // Handles error by logging a message to the console with the error message. 56 | function handleLocalMediaStreamError(error) { 57 | console.log('navigator.getUserMedia error: ', error); 58 | } 59 | -------------------------------------------------------------------------------- /src/main/java/io/github/benkoff/webrtcss/domain/RoomService.java: -------------------------------------------------------------------------------- 1 | package io.github.benkoff.webrtcss.domain; 2 | 3 | import io.github.benkoff.webrtcss.util.Parser; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.stereotype.Service; 6 | import org.springframework.web.socket.WebSocketSession; 7 | 8 | import java.util.Collections; 9 | import java.util.Comparator; 10 | import java.util.Map; 11 | import java.util.Optional; 12 | import java.util.Set; 13 | import java.util.TreeSet; 14 | 15 | @Service 16 | public class RoomService { 17 | private final Parser parser; 18 | // repository substitution since this is a very simple realization 19 | private final Set rooms = new TreeSet<>(Comparator.comparing(Room::getId)); 20 | 21 | @Autowired 22 | public RoomService(final Parser parser) { 23 | this.parser = parser; 24 | } 25 | 26 | public Set getRooms() { 27 | final TreeSet defensiveCopy = new TreeSet<>(Comparator.comparing(Room::getId)); 28 | defensiveCopy.addAll(rooms); 29 | 30 | return defensiveCopy; 31 | } 32 | 33 | public Boolean addRoom(final Room room) { 34 | return rooms.add(room); 35 | } 36 | 37 | public Optional findRoomByStringId(final String sid) { 38 | // simple get() because of parser errors handling 39 | return rooms.stream().filter(r -> r.getId().equals(parser.parseId(sid).get())).findAny(); 40 | } 41 | 42 | public Long getRoomId(Room room) { 43 | return room.getId(); 44 | } 45 | 46 | public Map getClients(final Room room) { 47 | return Optional.ofNullable(room) 48 | .map(r -> Collections.unmodifiableMap(r.getClients())) 49 | .orElse(Collections.emptyMap()); 50 | } 51 | 52 | public WebSocketSession addClient(final Room room, final String name, final WebSocketSession session) { 53 | return room.getClients().put(name, session); 54 | } 55 | 56 | public WebSocketSession removeClientByName(final Room room, final String name) { 57 | return room.getClients().remove(name); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /src/main/java/io/github/benkoff/webrtcss/controller/MainController.java: -------------------------------------------------------------------------------- 1 | package io.github.benkoff.webrtcss.controller; 2 | 3 | import io.github.benkoff.webrtcss.service.MainService; 4 | import org.springframework.beans.factory.annotation.Autowired; 5 | import org.springframework.stereotype.Controller; 6 | import org.springframework.validation.BindingResult; 7 | import org.springframework.web.bind.annotation.ControllerAdvice; 8 | import org.springframework.web.bind.annotation.GetMapping; 9 | import org.springframework.web.bind.annotation.ModelAttribute; 10 | import org.springframework.web.bind.annotation.PathVariable; 11 | import org.springframework.web.bind.annotation.PostMapping; 12 | import org.springframework.web.servlet.ModelAndView; 13 | 14 | @Controller 15 | @ControllerAdvice 16 | public class MainController { 17 | private final MainService mainService; 18 | 19 | @Autowired 20 | public MainController(final MainService mainService) { 21 | this.mainService = mainService; 22 | } 23 | 24 | @GetMapping({"", "/", "/index", "/home", "/main"}) 25 | public ModelAndView displayMainPage(final Long id, final String uuid) { 26 | return this.mainService.displayMainPage(id, uuid); 27 | } 28 | 29 | @PostMapping(value = "/room", params = "action=create") 30 | public ModelAndView processRoomSelection(@ModelAttribute("id") final String sid, @ModelAttribute("uuid") final String uuid, final BindingResult binding) { 31 | return this.mainService.processRoomSelection(sid, uuid, binding); 32 | } 33 | 34 | @GetMapping("/room/{sid}/user/{uuid}") 35 | public ModelAndView displaySelectedRoom(@PathVariable("sid") final String sid, @PathVariable("uuid") final String uuid) { 36 | return this.mainService.displaySelectedRoom(sid, uuid); 37 | } 38 | 39 | @GetMapping("/room/{sid}/user/{uuid}/exit") 40 | public ModelAndView processRoomExit(@PathVariable("sid") final String sid, @PathVariable("uuid") final String uuid) { 41 | return this.mainService.processRoomExit(sid, uuid); 42 | } 43 | 44 | @GetMapping("/room/random") 45 | public ModelAndView requestRandomRoomNumber(@ModelAttribute("uuid") final String uuid) { 46 | return mainService.requestRandomRoomNumber(uuid); 47 | } 48 | 49 | @GetMapping("/offer") 50 | public ModelAndView displaySampleSdpOffer() { 51 | return new ModelAndView("sdp_offer"); 52 | } 53 | 54 | @GetMapping("/stream") 55 | public ModelAndView displaySampleStreaming() { 56 | return new ModelAndView("streaming"); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/test/java/io/github/benkoff/webrtcss/socket/SignalHandlerTest.java: -------------------------------------------------------------------------------- 1 | package io.github.benkoff.webrtcss.socket; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import com.fasterxml.jackson.databind.ObjectWriter; 5 | import io.github.benkoff.webrtcss.domain.Room; 6 | import io.github.benkoff.webrtcss.domain.RoomService; 7 | import io.github.benkoff.webrtcss.domain.WebSocketMessage; 8 | import org.junit.After; 9 | import org.junit.Before; 10 | import org.junit.Test; 11 | import org.junit.runner.RunWith; 12 | import org.springframework.beans.factory.annotation.Autowired; 13 | import org.springframework.boot.test.context.SpringBootTest; 14 | import org.springframework.test.context.junit4.SpringRunner; 15 | import org.springframework.test.context.web.WebAppConfiguration; 16 | import org.springframework.web.socket.CloseStatus; 17 | import org.springframework.web.socket.TextMessage; 18 | import org.springframework.web.socket.WebSocketSession; 19 | 20 | import java.util.UUID; 21 | 22 | import static org.assertj.core.api.Assertions.assertThat; 23 | import static org.mockito.Mockito.mock; 24 | 25 | @RunWith(SpringRunner.class) 26 | @SpringBootTest 27 | @WebAppConfiguration 28 | public class SignalHandlerTest { 29 | @Autowired private RoomService service; 30 | @Autowired private SignalHandler handler; 31 | 32 | private String name; 33 | private WebSocketSession session; 34 | private Room room; 35 | 36 | @Before 37 | public void setup() { 38 | Long id = 1L; 39 | name = UUID.randomUUID().toString(); 40 | session = mock(WebSocketSession.class); 41 | room = new Room(id); 42 | service.addRoom(room); 43 | } 44 | 45 | @Test 46 | public void shouldRemoveClient_whenConnectionClosed() throws Exception { 47 | ObjectWriter ow = new ObjectMapper().writer().withDefaultPrettyPrinter(); 48 | WebSocketMessage message = new WebSocketMessage(name,"join", room.getId().toString(), null, null); 49 | handler.handleTextMessage(session, new TextMessage(ow.writeValueAsString(message))); 50 | message = new WebSocketMessage(name, "leave", room.getId().toString(), null, null); 51 | handler.handleTextMessage(session, new TextMessage(ow.writeValueAsString(message))); 52 | handler.afterConnectionClosed(session, CloseStatus.NORMAL); 53 | 54 | assertThat(service.getClients(room)) 55 | .isEmpty(); 56 | } 57 | 58 | @After 59 | public void teardown() { 60 | name = null; 61 | session = null; 62 | room = null; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/main/resources/templates/sdp_offer.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | SDP Offer 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |

Simple WebRTC Signaling Server

19 |
20 |

21 | This part creates a peer connection, then prints out the SDP 22 | generated by createOffer(), with the constraints checked. 23 |

24 | 25 |
26 |
27 |
28 |
29 | 30 | 31 |
32 |
33 | 34 | 35 |
36 |
37 | 38 | 39 |
40 |
41 | 42 | 43 |
44 |
45 |
46 | 47 |
48 |
49 |
50 | 51 |
52 |
53 |
54 |
55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /src/main/java/io/github/benkoff/webrtcss/domain/WebSocketMessage.java: -------------------------------------------------------------------------------- 1 | package io.github.benkoff.webrtcss.domain; 2 | 3 | import java.util.Objects; 4 | 5 | public class WebSocketMessage { 6 | private String from; 7 | private String type; 8 | private String data; 9 | private Object candidate; 10 | private Object sdp; 11 | 12 | public WebSocketMessage() { 13 | } 14 | 15 | public WebSocketMessage(final String from, 16 | final String type, 17 | final String data, 18 | final Object candidate, 19 | final Object sdp) { 20 | this.from = from; 21 | this.type = type; 22 | this.data = data; 23 | this.candidate = candidate; 24 | this.sdp = sdp; 25 | } 26 | 27 | public String getFrom() { 28 | return from; 29 | } 30 | 31 | public void setFrom(final String from) { 32 | this.from = from; 33 | } 34 | 35 | public String getType() { 36 | return type; 37 | } 38 | 39 | public void setType(final String type) { 40 | this.type = type; 41 | } 42 | 43 | public String getData() { 44 | return data; 45 | } 46 | 47 | public void setData(final String data) { 48 | this.data = data; 49 | } 50 | 51 | public Object getCandidate() { 52 | return candidate; 53 | } 54 | 55 | public void setCandidate(final Object candidate) { 56 | this.candidate = candidate; 57 | } 58 | 59 | public Object getSdp() { 60 | return sdp; 61 | } 62 | 63 | public void setSdp(final Object sdp) { 64 | this.sdp = sdp; 65 | } 66 | 67 | @Override 68 | public boolean equals(final Object o) { 69 | if (this == o) return true; 70 | if (o == null || getClass() != o.getClass()) return false; 71 | final WebSocketMessage message = (WebSocketMessage) o; 72 | return Objects.equals(getFrom(), message.getFrom()) && 73 | Objects.equals(getType(), message.getType()) && 74 | Objects.equals(getData(), message.getData()) && 75 | Objects.equals(getCandidate(), message.getCandidate()) && 76 | Objects.equals(getSdp(), message.getSdp()); 77 | } 78 | 79 | @Override 80 | public int hashCode() { 81 | 82 | return Objects.hash(getFrom(), getType(), getData(), getCandidate(), getSdp()); 83 | } 84 | 85 | @Override 86 | public String toString() { 87 | return "WebSocketMessage{" + 88 | "from='" + from + '\'' + 89 | ", type='" + type + '\'' + 90 | ", data='" + data + '\'' + 91 | ", candidate=" + candidate + 92 | ", sdp=" + sdp + 93 | '}'; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/main/resources/templates/main.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Main Page 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |

Simple WebRTC Signaling Server

19 |
20 |

21 | This part receives a room number (or generates new one), and redirects current user there. 22 |

23 |
24 | 25 |
26 |
27 |
28 |
29 |

30 | 31 | 32 | 35 | 36 | 37 |

38 |
39 |
40 | 42 | 44 |
45 |
46 | 47 | 48 | 49 | 51 |
52 |
53 |
54 |
55 |
56 |
57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /src/main/java/io/github/benkoff/webrtcss/service/MainServiceImpl.java: -------------------------------------------------------------------------------- 1 | package io.github.benkoff.webrtcss.service; 2 | 3 | import io.github.benkoff.webrtcss.domain.Room; 4 | import io.github.benkoff.webrtcss.domain.RoomService; 5 | import io.github.benkoff.webrtcss.util.Parser; 6 | import org.slf4j.Logger; 7 | import org.slf4j.LoggerFactory; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.stereotype.Service; 10 | import org.springframework.validation.BindingResult; 11 | import org.springframework.web.servlet.ModelAndView; 12 | 13 | import java.util.Optional; 14 | import java.util.concurrent.ThreadLocalRandom; 15 | 16 | @Service 17 | public class MainServiceImpl implements MainService { 18 | private final Logger logger = LoggerFactory.getLogger(this.getClass()); 19 | private static final String REDIRECT = "redirect:/"; 20 | 21 | private final RoomService roomService; 22 | private final Parser parser; 23 | 24 | @Autowired 25 | public MainServiceImpl(final RoomService roomService, final Parser parser) { 26 | this.roomService = roomService; 27 | this.parser = parser; 28 | } 29 | 30 | @Override 31 | public ModelAndView displayMainPage(final Long id, final String uuid) { 32 | final ModelAndView modelAndView = new ModelAndView("main"); 33 | modelAndView.addObject("id", id); 34 | modelAndView.addObject("rooms", roomService.getRooms()); 35 | modelAndView.addObject("uuid", uuid); 36 | 37 | return modelAndView; 38 | } 39 | 40 | @Override 41 | public ModelAndView processRoomSelection(final String sid, final String uuid, final BindingResult bindingResult) { 42 | if (bindingResult.hasErrors()) { 43 | // simplified version, no errors processing 44 | return new ModelAndView(REDIRECT); 45 | } 46 | Optional optionalId = parser.parseId(sid); 47 | optionalId.ifPresent(id -> Optional.ofNullable(uuid).ifPresent(name -> roomService.addRoom(new Room(id)))); 48 | 49 | return this.displayMainPage(optionalId.orElse(null), uuid); 50 | } 51 | 52 | @Override 53 | public ModelAndView displaySelectedRoom(final String sid, final String uuid) { 54 | // redirect to main page if provided data is invalid 55 | ModelAndView modelAndView = new ModelAndView(REDIRECT); 56 | 57 | if (parser.parseId(sid).isPresent()) { 58 | Room room = roomService.findRoomByStringId(sid).orElse(null); 59 | if(room != null && uuid != null && !uuid.isEmpty()) { 60 | logger.debug("User {} is going to join Room #{}", uuid, sid); 61 | // open the chat room 62 | modelAndView = new ModelAndView("chat_room", "id", sid); 63 | modelAndView.addObject("uuid", uuid); 64 | } 65 | } 66 | 67 | return modelAndView; 68 | } 69 | 70 | @Override 71 | public ModelAndView processRoomExit(final String sid, final String uuid) { 72 | if(sid != null && uuid != null) { 73 | logger.debug("User {} has left Room #{}", uuid, sid); 74 | // implement any logic you need 75 | } 76 | return new ModelAndView(REDIRECT); 77 | } 78 | 79 | @Override 80 | public ModelAndView requestRandomRoomNumber(final String uuid) { 81 | return this.displayMainPage(randomValue(), uuid); 82 | } 83 | 84 | private Long randomValue() { 85 | return ThreadLocalRandom.current().nextLong(0, 100); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/main/resources/templates/chat_room.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Chat Room 7 | 8 | 9 | 10 | 11 | 12 | 31 | 32 | 33 | 34 | 35 | 36 |
37 |

Simple WebRTC Signalling Server

38 | 39 |
40 |
41 | Local User Id 42 |
43 |
44 |
45 |
46 |
47 |
48 | 51 | 54 |
55 |
56 | 59 | 62 |
63 |
64 | 65 | 66 | 67 | 70 | 71 |
72 |
73 |
74 | 75 |
76 |
77 | 78 |
79 |
80 | 81 |
82 |
83 |
84 |
85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebRTC-SS 2 | ## WebRTC Signaling Server & Simple Video Chat 3 | 4 | ## Simple Signaling Server to provide basic WebRTC functionality 5 | 1. Create chat rooms providing their links web publishing; 6 | 2. Establish client-service WebSocket connections; 7 | 3. Implement WebRTC SDP Offers and ICE Candidates negotiation; 8 | 4. Test peer-to-peer video chatting. 9 | 10 | ### Project Desription 11 | This project has been built with Gradle, server side is written in Java 8 using Spring Boot, client is in Java Script. 12 | Application web entry: http://localhost:8080 (https://localhost:4883 for the Secure Test version on the test branch). 13 | The back side uses the following frameworks and technologies: 14 | * Spring Boot 2.0.5 with starter web, validation, and thymeleaf dependencies needed for the web server; 15 | * Spring Web Socket to establish connection between signalling server and clients; 16 | * HTML 5 to provide Web RTC interaction between clients; 17 | * Spring Boot Starter-test provides JUnit 4, Mockito, AssertJ, other libraries used to test back-side. 18 | 19 | ### Git Repository Structure 20 | This GitHub repository has 3 branches: 21 | * main - latest stable version working on http://localhost:8080 22 | * develop - to maintain product development; 23 | * test - includes self-signed sertificates to test video chatting on mobile devices (such as Android Chromium, which forbid http connection to local host), use https://localhost:4883 to reach this version, and let your browser ingnore a warning about the certificate. 24 | 25 | ### Project Structure 26 | * MainController provides HTTP requests handling, model processing and view presentation; 27 | * Domain package includes domain model and service; 28 | * Web Socket based Web RTC Signalling Server is located under the Socket directory; 29 | * Config and Util packages contain configuration and utility classes respectively; 30 | * Test section contains unit and integration backside tests; 31 | * Resources folder keeps application.yml configuration file, certificates (in test branch), frontside templates and static content (CSS, Java Script files). 32 | 33 | ### API 34 | Method | URI | Description 35 | ------ | --------------------------------------------------- | ------- 36 | Get | "", "/", "/index", "/home", "/main" | main page application web entry point 37 | Post | "/room" | process room selection form 38 | Get | "/room/{sid}/user/{uuid}" | select a room to enter; sid - room number, uuid - user id 39 | Get | "/room/{sid}/user/{uuid}/exit" | a room exit point for the user selected 40 | Get | "/room/random" | generates random room number 41 | Get | "/offer" | demonstrates sample SDP offer 42 | Get | "/stream" | demonstrates streaming video resolution selection 43 | 44 | ### Building and Running Docker Image 45 | 1. Build a Docker image running in the Terminal ``./gradlew build docker --info`` 46 | 2. Select recently built image, tagged ``benkoff/webrtcss-spring-boot-docker:latest`` 47 | 3. Push selected image to Docker hub 48 | 4. Pull and run uploaded image in local Docker with ``docker run -p 8080:8080 -t benkoff/webrtcss-spring-boot-docker 49 | `` 50 | 51 | ### Useful Links and Sources 52 | **Documentation and Tutorials:** 53 | * https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Signaling_and_video_calling 54 | * https://codelabs.developers.google.com/codelabs/webrtc-web/ 55 | * https://webrtc.github.io/samples/ 56 | * https://andrewjprokop.wordpress.com/2014/07/21/understanding-webrtc-media-connections-ice-stun-and-turn/ 57 | * https://nextrtc.org/ 58 | * https://www.html5rocks.com/en/tutorials/webrtc/basics/ 59 | * http://w3c.github.io/webrtc-pc/ 60 | * https://www.scaledrone.com/blog/webrtc-chat-tutorial/ 61 | * http://builds.kurento.org/release/stable/docs/tutorials/node/tutorial-4-one2one.html 62 | 63 | **Sample Implementations:** 64 | * https://github.com/webrtc/samples 65 | * https://github.com/webrtc/apprtc/blob/master/src/collider/collider/collider.go 66 | * https://github.com/mslosarz/nextrtc-signaling-server 67 | * https://github.com/Kurento/kurento-tutorial-java/blob/master/kurento-one2one-call-advanced/src/main/java/org/kurento/tutorial/one2onecalladv 68 | 69 | **WebSockets:** 70 | * https://docs.spring.io/spring/docs/current/spring-framework-reference/web.html#websocket 71 | * https://keyholesoftware.com/2017/04/10/websockets-with-spring-boot/ 72 | * https://www.baeldung.com/websockets-spring 73 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn ( ) { 37 | echo "$*" 38 | } 39 | 40 | die ( ) { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save ( ) { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /src/main/java/io/github/benkoff/webrtcss/socket/SignalHandler.java: -------------------------------------------------------------------------------- 1 | package io.github.benkoff.webrtcss.socket; 2 | 3 | import com.fasterxml.jackson.databind.ObjectMapper; 4 | import io.github.benkoff.webrtcss.domain.Room; 5 | import io.github.benkoff.webrtcss.domain.RoomService; 6 | import io.github.benkoff.webrtcss.domain.WebSocketMessage; 7 | import org.slf4j.Logger; 8 | import org.slf4j.LoggerFactory; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.stereotype.Component; 11 | import org.springframework.web.socket.CloseStatus; 12 | import org.springframework.web.socket.TextMessage; 13 | import org.springframework.web.socket.WebSocketSession; 14 | import org.springframework.web.socket.handler.TextWebSocketHandler; 15 | 16 | import java.io.IOException; 17 | import java.util.HashMap; 18 | import java.util.Map; 19 | import java.util.Objects; 20 | import java.util.Optional; 21 | 22 | @Component 23 | public class SignalHandler extends TextWebSocketHandler { 24 | @Autowired private RoomService roomService; 25 | 26 | private final Logger logger = LoggerFactory.getLogger(this.getClass()); 27 | private final ObjectMapper objectMapper = new ObjectMapper(); 28 | 29 | // session id to room mapping 30 | private Map sessionIdToRoomMap = new HashMap<>(); 31 | 32 | // message types, used in signalling: 33 | // text message 34 | private static final String MSG_TYPE_TEXT = "text"; 35 | // SDP Offer message 36 | private static final String MSG_TYPE_OFFER = "offer"; 37 | // SDP Answer message 38 | private static final String MSG_TYPE_ANSWER = "answer"; 39 | // New ICE Candidate message 40 | private static final String MSG_TYPE_ICE = "ice"; 41 | // join room data message 42 | private static final String MSG_TYPE_JOIN = "join"; 43 | // leave room data message 44 | private static final String MSG_TYPE_LEAVE = "leave"; 45 | 46 | @Override 47 | public void afterConnectionClosed(final WebSocketSession session, final CloseStatus status) { 48 | logger.debug("[ws] Session has been closed with status {}", status); 49 | sessionIdToRoomMap.remove(session.getId()); 50 | } 51 | 52 | @Override 53 | public void afterConnectionEstablished(final WebSocketSession session) { 54 | // webSocket has been opened, send a message to the client 55 | // when data field contains 'true' value, the client starts negotiating 56 | // to establish peer-to-peer connection, otherwise they wait for a counterpart 57 | sendMessage(session, new WebSocketMessage("Server", MSG_TYPE_JOIN, Boolean.toString(!sessionIdToRoomMap.isEmpty()), null, null)); 58 | } 59 | 60 | @Override 61 | protected void handleTextMessage(final WebSocketSession session, final TextMessage textMessage) { 62 | // a message has been received 63 | try { 64 | WebSocketMessage message = objectMapper.readValue(textMessage.getPayload(), WebSocketMessage.class); 65 | logger.debug("[ws] Message of {} type from {} received", message.getType(), message.getFrom()); 66 | String userName = message.getFrom(); // origin of the message 67 | String data = message.getData(); // payload 68 | 69 | Room room; 70 | switch (message.getType()) { 71 | // text message from client has been received 72 | case MSG_TYPE_TEXT: 73 | logger.debug("[ws] Text message: {}", message.getData()); 74 | // message.data is the text sent by client 75 | // process text message if needed 76 | break; 77 | 78 | // process signal received from client 79 | case MSG_TYPE_OFFER: 80 | case MSG_TYPE_ANSWER: 81 | case MSG_TYPE_ICE: 82 | Object candidate = message.getCandidate(); 83 | Object sdp = message.getSdp(); 84 | logger.debug("[ws] Signal: {}", 85 | candidate != null 86 | ? candidate.toString().substring(0, 64) 87 | : sdp.toString().substring(0, 64)); 88 | 89 | Room rm = sessionIdToRoomMap.get(session.getId()); 90 | if (rm != null) { 91 | Map clients = roomService.getClients(rm); 92 | for(Map.Entry client : clients.entrySet()) { 93 | // send messages to all clients except current user 94 | if (!client.getKey().equals(userName)) { 95 | // select the same type to resend signal 96 | sendMessage(client.getValue(), 97 | new WebSocketMessage( 98 | userName, 99 | message.getType(), 100 | data, 101 | candidate, 102 | sdp)); 103 | } 104 | } 105 | } 106 | break; 107 | 108 | // identify user and their opponent 109 | case MSG_TYPE_JOIN: 110 | // message.data contains connected room id 111 | logger.debug("[ws] {} has joined Room: #{}", userName, message.getData()); 112 | room = roomService.findRoomByStringId(data) 113 | .orElseThrow(() -> new IOException("Invalid room number received!")); 114 | // add client to the Room clients list 115 | roomService.addClient(room, userName, session); 116 | sessionIdToRoomMap.put(session.getId(), room); 117 | break; 118 | 119 | case MSG_TYPE_LEAVE: 120 | // message data contains connected room id 121 | logger.debug("[ws] {} is going to leave Room: #{}", userName, message.getData()); 122 | // room id taken by session id 123 | room = sessionIdToRoomMap.get(session.getId()); 124 | // remove the client which leaves from the Room clients list 125 | Optional client = roomService.getClients(room).entrySet().stream() 126 | .filter(entry -> Objects.equals(entry.getValue().getId(), session.getId())) 127 | .map(Map.Entry::getKey) 128 | .findAny(); 129 | client.ifPresent(c -> roomService.removeClientByName(room, c)); 130 | break; 131 | 132 | // something should be wrong with the received message, since it's type is unrecognizable 133 | default: 134 | logger.debug("[ws] Type of the received message {} is undefined!", message.getType()); 135 | // handle this if needed 136 | } 137 | 138 | } catch (IOException e) { 139 | logger.debug("An error occured: {}", e.getMessage()); 140 | } 141 | } 142 | 143 | private void sendMessage(WebSocketSession session, WebSocketMessage message) { 144 | try { 145 | String json = objectMapper.writeValueAsString(message); 146 | session.sendMessage(new TextMessage(json)); 147 | } catch (IOException e) { 148 | logger.debug("An error occured: {}", e.getMessage()); 149 | } 150 | } 151 | } 152 | -------------------------------------------------------------------------------- /src/test/java/io/github/benkoff/webrtcss/controller/MainControllerTest.java: -------------------------------------------------------------------------------- 1 | package io.github.benkoff.webrtcss.controller; 2 | 3 | import io.github.benkoff.webrtcss.domain.Room; 4 | import io.github.benkoff.webrtcss.service.MainService; 5 | import org.hamcrest.Matchers; 6 | import org.junit.Before; 7 | import org.junit.Test; 8 | import org.junit.runner.RunWith; 9 | import org.springframework.beans.factory.annotation.Autowired; 10 | import org.springframework.boot.test.context.SpringBootTest; 11 | import org.springframework.test.annotation.Repeat; 12 | import org.springframework.test.context.junit4.SpringRunner; 13 | import org.springframework.test.context.web.WebAppConfiguration; 14 | import org.springframework.test.web.servlet.MockMvc; 15 | import org.springframework.test.web.servlet.setup.MockMvcBuilders; 16 | import org.springframework.util.LinkedMultiValueMap; 17 | import org.springframework.util.MultiValueMap; 18 | import org.springframework.web.context.WebApplicationContext; 19 | 20 | import java.util.UUID; 21 | 22 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 23 | import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; 24 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; 25 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; 26 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; 27 | import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; 28 | 29 | @RunWith(SpringRunner.class) 30 | @SpringBootTest 31 | @WebAppConfiguration 32 | public class MainControllerTest { 33 | @Autowired private WebApplicationContext webApplicationContext; 34 | @Autowired private MainService mainService; 35 | private MainController controller; 36 | private MockMvc mockMvc; 37 | 38 | private Long expectedId; 39 | private String host; 40 | private String uuid; 41 | private Room expectedRoom; 42 | private MultiValueMap map; 43 | 44 | @Before 45 | public void setup() { 46 | controller = new MainController(mainService); 47 | mockMvc = MockMvcBuilders 48 | .webAppContextSetup(webApplicationContext) 49 | .build(); 50 | 51 | expectedId = 33L; 52 | host = UUID.randomUUID().toString(); 53 | uuid = UUID.randomUUID().toString(); 54 | map = new LinkedMultiValueMap<>(); 55 | expectedRoom = new Room(expectedId); 56 | map.add("id", expectedId.toString()); 57 | map.add("action", "create"); 58 | } 59 | 60 | @Test 61 | public void shouldReturnMainViewStatusOk_whenDisplayMainPage() throws Exception { 62 | mockMvc.perform(get("/")) 63 | .andExpect(status().isOk()) 64 | .andExpect(view().name("main")) 65 | .andExpect(content().contentType("text/html;charset=UTF-8")) 66 | .andExpect(content().string(Matchers.containsString("Main Page"))); 67 | } 68 | 69 | @Test 70 | public void shouldAddRoomWithNumberSelected_whenProcessRoomSelection() throws Exception { 71 | mockMvc.perform(get("/")) 72 | .andExpect(model().attribute("rooms", Matchers.empty())); 73 | mockMvc.perform(post("/room").params(map)); 74 | mockMvc.perform(get("/")) 75 | .andExpect(model().attribute("rooms", Matchers.contains(expectedRoom))); 76 | } 77 | 78 | @Test 79 | public void shouldReturnChatRoomOk_whenDisplaySelectedRoom_paramsOk() throws Exception { 80 | mockMvc.perform(post("/room").params(map)); 81 | mockMvc.perform(get("/room/" + expectedId + "/user/" + uuid)) 82 | .andExpect(status().isOk()) 83 | .andExpect(view().name("chat_room")) 84 | .andExpect(content().contentType("text/html;charset=UTF-8")) 85 | .andExpect(content().string(Matchers.containsString("Chat Room"))); 86 | mockMvc.perform(get("/")) 87 | .andExpect(model().attribute("rooms", 88 | Matchers.hasItem( 89 | Matchers. hasProperty("id", 90 | Matchers.equalTo(expectedId))))); 91 | } 92 | 93 | @Test 94 | public void shouldReturnChatRoomOk_whenDisplaySelectedRoom_withHost() throws Exception { 95 | mockMvc.perform(post("/room").params(map)); 96 | mockMvc.perform(get("/room/" + expectedId + "/user/" + host)) 97 | .andExpect(status().isOk()) 98 | .andExpect(view().name("chat_room")) 99 | .andExpect(content().contentType("text/html;charset=UTF-8")) 100 | .andExpect(content().string(Matchers.containsString("Chat Room"))); 101 | mockMvc.perform(get("/")) 102 | .andExpect(model().attribute("rooms", 103 | Matchers.hasItem( 104 | Matchers. hasProperty("id", 105 | Matchers.equalTo(expectedId))))); 106 | } 107 | 108 | @Test 109 | public void shouldRedirect_whenDisplaySelectedRoom_withInvalidRoom() throws Exception { 110 | Long invalidValue = 99L; 111 | String visitor = UUID.randomUUID().toString(); 112 | 113 | mockMvc.perform(post("/room").params(map)); 114 | mockMvc.perform(get("/room/" + invalidValue + "/user/" + visitor)) 115 | .andExpect(status().is3xxRedirection()) 116 | .andExpect(view().name("redirect:/")); 117 | mockMvc.perform(get("/")) 118 | .andExpect(model().attribute("rooms", 119 | Matchers.hasItem( 120 | Matchers. hasProperty("id", 121 | Matchers.equalTo(expectedId))))) 122 | .andExpect(model().attribute("rooms", 123 | Matchers.not( 124 | Matchers.hasItem( 125 | Matchers. hasProperty("id", 126 | Matchers.equalTo(invalidValue)))))); 127 | } 128 | 129 | @Repeat(10) 130 | @Test 131 | public void shouldReturnMainViewStatusOk_whenRequestRandomRoomNumber() throws Exception { 132 | mockMvc.perform(get("/room/random")) 133 | .andExpect(status().isOk()) 134 | .andExpect(view().name("main")) 135 | .andExpect(content().contentType("text/html;charset=UTF-8")) 136 | .andExpect(content().string(Matchers.containsString("Main Page"))) 137 | .andExpect(model().attribute("id", Matchers.greaterThan(-1L))) 138 | .andExpect(model().attribute("id", Matchers.lessThan(100L))); 139 | } 140 | 141 | @Test 142 | public void shouldReturnStreamViewStatusOk_whenDisplaySampleSdpOffer() throws Exception { 143 | mockMvc.perform(get("/offer")) 144 | .andExpect(status().isOk()) 145 | .andExpect(view().name("sdp_offer")) 146 | .andExpect(content().contentType("text/html;charset=UTF-8")) 147 | .andExpect(content().string(Matchers.containsString("SDP Offer"))); 148 | } 149 | 150 | @Test 151 | public void shouldReturnStreamViewStatusOk_whenDisplaySampleStreaming() throws Exception { 152 | mockMvc.perform(get("/stream")) 153 | .andExpect(status().isOk()) 154 | .andExpect(view().name("streaming")) 155 | .andExpect(content().contentType("text/html;charset=UTF-8")) 156 | .andExpect(content().string(Matchers.containsString("Sample Streaming"))); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/main/resources/static/js/old_client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const socket = new WebSocket('ws://' + window.location.host + '/signal:6503'); 3 | const peerConnectionConfig = { 4 | 'iceServers': [ 5 | {'urls': 'stun:stun.stunprotocol.org:3478'}, 6 | {'urls': 'stun:stun.l.google.com:19302'}, 7 | ] 8 | }; 9 | 10 | const videoButtonOff = document.querySelector('#video_off'); 11 | const videoButtonOn = document.querySelector('#video_on'); 12 | const audioButtonOff = document.querySelector('#audio_off'); 13 | const audioButtonOn = document.querySelector('#audio_on'); 14 | const exitButton = document.querySelector('#exit'); 15 | const localVideo = document.querySelector('#local_video'); 16 | const remoteVideo = document.querySelector('#remote_video'); 17 | const localRoom = document.querySelector('input#id').value; 18 | const localUserName = localStorage.getItem("uuid"); 19 | 20 | let localStream; 21 | let localVideoTracks; 22 | let myPeerConnection; 23 | 24 | const mediaConstraints = { 25 | audio: true, 26 | video: true 27 | }; 28 | 29 | // run when page loads 30 | $(function(){ 31 | start(); 32 | }); 33 | 34 | // add an event listener to get to know when a connection is open 35 | socket.onopen = function() { 36 | console.log('WebSocket connection opened. Room: ' + localRoom); 37 | // send a message to the server 38 | sendToServer({ 39 | from: localUserName, 40 | type: 'join', 41 | data: localRoom 42 | }); 43 | }; 44 | 45 | // add an event listener for a message being received 46 | socket.onmessage = function(message) { 47 | const webSocketMessage = JSON.parse(message.data); 48 | if (webSocketMessage.type === 'text') { 49 | console.log('Text message from ' + webSocketMessage.from + ' received: ' + webSocketMessage.data); 50 | } else if (webSocketMessage.type === 'signal') { 51 | console.log('Signal received from server'); 52 | if (message.data.sdp) { 53 | console.log('SDP received from server'); 54 | 55 | myPeerConnection.setRemoteDescription( 56 | new RTCSessionDescription(webSocketMessage.data.sdp)) 57 | .then(function() { 58 | if (myPeerConnection.remoteDescription.type === 'offer') { 59 | handleOfferMessage(message); 60 | //TODO 61 | console.log('remoteDescription.type == offer'); 62 | } 63 | }); 64 | } else { 65 | console.log('Candidate received from server'); 66 | // myPeerConnection.addIceCandidate(new RTCIceCandidate(message.data.candidate)); 67 | //TODO 68 | } 69 | } else { 70 | // this should never happen 71 | console.log('Error: Wrong type message received from server'); 72 | } 73 | }; 74 | 75 | // a listener for the socket being closed event 76 | socket.onclose = function(message) { 77 | console.log('Socket has been closed'); 78 | }; 79 | 80 | // an event listener to handle errors 81 | // socket.onerror = function(message) { 82 | // console.log("Error: " + message); 83 | // }; 84 | 85 | // use JSON format to send WebSocket message 86 | function sendToServer(msg) { 87 | let msgJSON = JSON.stringify(msg); 88 | socket.send(msgJSON); 89 | } 90 | 91 | // mute video 92 | videoButtonOff.onclick = () => { 93 | localVideoTracks = localStream.getVideoTracks(); 94 | localVideoTracks.forEach(track => localStream.removeTrack(track)); 95 | $(localVideo).css('display', 'none'); 96 | console.log('Video Off'); 97 | }; 98 | videoButtonOn.onclick = () => { 99 | localVideoTracks.forEach(track => localStream.addTrack(track)); 100 | $(localVideo).css('display', 'inline'); 101 | console.log('Video On'); 102 | }; 103 | 104 | // mute audio 105 | audioButtonOff.onclick = () => { 106 | localVideo.muted = true; 107 | console.log('Audio Off'); 108 | }; 109 | audioButtonOn.onclick = () => { 110 | localVideo.muted = false; 111 | console.log('Audio On'); 112 | }; 113 | 114 | // close socket when exit 115 | exitButton.onclick = () => { 116 | stop(); 117 | }; 118 | 119 | // create peer connection, init local stream 120 | function start() { 121 | createPeerConnection(); 122 | getMedia(mediaConstraints); 123 | } 124 | 125 | function stop() { 126 | // send a message to the server 127 | sendToServer({ 128 | from: localUserName, 129 | type: 'text', 130 | data: 'Client ' + localUserName +' disconnected' 131 | }); 132 | if (socket != null) { 133 | socket.close(); 134 | } 135 | console.log('Socket closed'); 136 | } 137 | 138 | // initialize media stream 139 | function getMedia(constraints) { 140 | if (localStream) { 141 | localStream.getTracks().forEach(track => { 142 | track.stop(); 143 | }); 144 | } 145 | navigator.mediaDevices.getUserMedia(constraints) 146 | .then(getLocalMediaStream).catch(handleGetUserMediaError); 147 | } 148 | 149 | // handle get media error 150 | function handleGetUserMediaError(error) { 151 | console.log('navigator.getUserMedia error: ', error); 152 | switch(error.name) { 153 | case "NotFoundError": 154 | alert("Unable to open your call because no camera and/or microphone" + 155 | "were found."); 156 | break; 157 | case "SecurityError": 158 | case "PermissionDeniedError": 159 | // Do nothing; this is the same as the user canceling the call. 160 | break; 161 | default: 162 | alert("Error opening your camera and/or microphone: " + error.message); 163 | break; 164 | } 165 | 166 | stop(); 167 | } 168 | 169 | // add MediaStream to local video element and to the Peer 170 | function getLocalMediaStream(mediaStream) { 171 | localStream = mediaStream; 172 | localVideo.srcObject = mediaStream; 173 | localStream.getTracks().forEach(track => myPeerConnection.addTrack(track, localStream)); 174 | } 175 | 176 | function createPeerConnection() { 177 | myPeerConnection = new RTCPeerConnection(peerConnectionConfig); 178 | 179 | myPeerConnection.onicecandidate = handleICECandidateEvent; 180 | // myPeerConnection.ontrack = handleTrackEvent; 181 | myPeerConnection.onnegotiationneeded = handleNegotiationNeededEvent; 182 | // myPeerConnection.onremovetrack = handleRemoveTrackEvent; 183 | // myPeerConnection.oniceconnectionstatechange = handleICEConnectionStateChangeEvent; 184 | // myPeerConnection.onicegatheringstatechange = handleICEGatheringStateChangeEvent; 185 | // myPeerConnection.onsignalingstatechange = handleSignalingStateChangeEvent; 186 | } 187 | 188 | function handleICECandidateEvent(event) { 189 | if (event.candidate) { 190 | sendToServer({ 191 | from: localUserName, 192 | type: 'signal', 193 | data: event.candidate 194 | }); 195 | console.log('handleICECandidateEvent: ICE candidate sent'); 196 | } 197 | } 198 | 199 | function handleOfferMessage(message) { 200 | myPeerConnection.createAnswer().then(function(answer) { 201 | return myPeerConnection.setLocalDescription(answer); 202 | }) 203 | .then(function() { 204 | sendToServer({ 205 | from: localUserName, 206 | type: 'signal', 207 | data: { 208 | 'sdp': myPeerConnection.localDescription 209 | } 210 | }); 211 | console.log('handleOfferMessage: SDP answer sent'); 212 | }) 213 | .catch(function(reason) { 214 | // an error occurred, so handle the failure to connect 215 | console.log('failure to connect error: ', reason); 216 | }); 217 | } 218 | 219 | function handleNegotiationNeededEvent() { 220 | myPeerConnection.createOffer().then(function(offer) { 221 | return myPeerConnection.setLocalDescription(offer); 222 | }) 223 | .then(function() { 224 | sendToServer({ 225 | from: localUserName, 226 | type: 'signal', 227 | data: { 228 | 'sdp': myPeerConnection.localDescription 229 | } 230 | }); 231 | console.log('handleNegotiationNeededEvent: SDP offer sent'); 232 | }) 233 | .catch(function(reason) { 234 | // an error occurred, so handle the failure to connect 235 | console.log('failure to connect error: ', reason); 236 | }); 237 | } 238 | 239 | //TODO replace this temporary solution 240 | function logError(error) { 241 | console.log(error.name + ': ' + error.message); 242 | } 243 | -------------------------------------------------------------------------------- /src/main/resources/static/js/webrtc_client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | // create and run Web Socket connection 3 | const socket = new WebSocket("ws://" + window.location.host + "/signal"); 4 | 5 | // UI elements 6 | const videoButtonOff = document.querySelector('#video_off'); 7 | const videoButtonOn = document.querySelector('#video_on'); 8 | const audioButtonOff = document.querySelector('#audio_off'); 9 | const audioButtonOn = document.querySelector('#audio_on'); 10 | const exitButton = document.querySelector('#exit'); 11 | const localRoom = document.querySelector('input#id').value; 12 | const localVideo = document.getElementById('local_video'); 13 | const remoteVideo = document.getElementById('remote_video'); 14 | const localUserName = localStorage.getItem("uuid"); 15 | 16 | // WebRTC STUN servers 17 | const peerConnectionConfig = { 18 | 'iceServers': [ 19 | {'urls': 'stun:stun.stunprotocol.org:3478'}, 20 | {'urls': 'stun:stun.l.google.com:19302'}, 21 | ] 22 | }; 23 | 24 | // WebRTC media 25 | const mediaConstraints = { 26 | audio: true, 27 | video: true 28 | }; 29 | 30 | // WebRTC variables 31 | let localStream; 32 | let localVideoTracks; 33 | let myPeerConnection; 34 | 35 | // on page load runner 36 | $(function(){ 37 | start(); 38 | }); 39 | 40 | function start() { 41 | // add an event listener for a message being received 42 | socket.onmessage = function(msg) { 43 | let message = JSON.parse(msg.data); 44 | switch (message.type) { 45 | case "text": 46 | log('Text message from ' + message.from + ' received: ' + message.data); 47 | break; 48 | 49 | case "offer": 50 | log('Signal OFFER received'); 51 | handleOfferMessage(message); 52 | break; 53 | 54 | case "answer": 55 | log('Signal ANSWER received'); 56 | handleAnswerMessage(message); 57 | break; 58 | 59 | case "ice": 60 | log('Signal ICE Candidate received'); 61 | handleNewICECandidateMessage(message); 62 | break; 63 | 64 | case "join": 65 | log('Client is starting to ' + (message.data === "true)" ? 'negotiate' : 'wait for a peer')); 66 | handlePeerConnection(message); 67 | break; 68 | 69 | default: 70 | handleErrorMessage('Wrong type message received from server'); 71 | } 72 | }; 73 | 74 | // add an event listener to get to know when a connection is open 75 | socket.onopen = function() { 76 | log('WebSocket connection opened to Room: #' + localRoom); 77 | // send a message to the server to join selected room with Web Socket 78 | sendToServer({ 79 | from: localUserName, 80 | type: 'join', 81 | data: localRoom 82 | }); 83 | }; 84 | 85 | // a listener for the socket being closed event 86 | socket.onclose = function(message) { 87 | log('Socket has been closed'); 88 | }; 89 | 90 | // an event listener to handle socket errors 91 | socket.onerror = function(message) { 92 | handleErrorMessage("Error: " + message); 93 | }; 94 | } 95 | 96 | function stop() { 97 | // send a message to the server to remove this client from the room clients list 98 | log("Send 'leave' message to server"); 99 | sendToServer({ 100 | from: localUserName, 101 | type: 'leave', 102 | data: localRoom 103 | }); 104 | 105 | if (myPeerConnection) { 106 | log('Close the RTCPeerConnection'); 107 | 108 | // disconnect all our event listeners 109 | myPeerConnection.onicecandidate = null; 110 | myPeerConnection.ontrack = null; 111 | myPeerConnection.onnegotiationneeded = null; 112 | myPeerConnection.oniceconnectionstatechange = null; 113 | myPeerConnection.onsignalingstatechange = null; 114 | myPeerConnection.onicegatheringstatechange = null; 115 | myPeerConnection.onnotificationneeded = null; 116 | myPeerConnection.onremovetrack = null; 117 | 118 | // Stop the videos 119 | if (remoteVideo.srcObject) { 120 | remoteVideo.srcObject.getTracks().forEach(track => track.stop()); 121 | } 122 | if (localVideo.srcObject) { 123 | localVideo.srcObject.getTracks().forEach(track => track.stop()); 124 | } 125 | 126 | remoteVideo.src = null; 127 | localVideo.src = null; 128 | 129 | // close the peer connection 130 | myPeerConnection.close(); 131 | myPeerConnection = null; 132 | 133 | log('Close the socket'); 134 | if (socket != null) { 135 | socket.close(); 136 | } 137 | } 138 | } 139 | 140 | /* 141 | UI Handlers 142 | */ 143 | // mute video buttons handler 144 | videoButtonOff.onclick = () => { 145 | localVideoTracks = localStream.getVideoTracks(); 146 | localVideoTracks.forEach(track => localStream.removeTrack(track)); 147 | $(localVideo).css('display', 'none'); 148 | log('Video Off'); 149 | }; 150 | videoButtonOn.onclick = () => { 151 | localVideoTracks.forEach(track => localStream.addTrack(track)); 152 | $(localVideo).css('display', 'inline'); 153 | log('Video On'); 154 | }; 155 | 156 | // mute audio buttons handler 157 | audioButtonOff.onclick = () => { 158 | localVideo.muted = true; 159 | log('Audio Off'); 160 | }; 161 | audioButtonOn.onclick = () => { 162 | localVideo.muted = false; 163 | log('Audio On'); 164 | }; 165 | 166 | // room exit button handler 167 | exitButton.onclick = () => { 168 | stop(); 169 | }; 170 | 171 | function log(message) { 172 | console.log(message); 173 | } 174 | 175 | function handleErrorMessage(message) { 176 | console.error(message); 177 | } 178 | 179 | // use JSON format to send WebSocket message 180 | function sendToServer(msg) { 181 | let msgJSON = JSON.stringify(msg); 182 | socket.send(msgJSON); 183 | } 184 | 185 | // initialize media stream 186 | function getMedia(constraints) { 187 | if (localStream) { 188 | localStream.getTracks().forEach(track => { 189 | track.stop(); 190 | }); 191 | } 192 | navigator.mediaDevices.getUserMedia(constraints) 193 | .then(getLocalMediaStream).catch(handleGetUserMediaError); 194 | } 195 | 196 | // create peer connection, get media, start negotiating when second participant appears 197 | function handlePeerConnection(message) { 198 | createPeerConnection(); 199 | getMedia(mediaConstraints); 200 | if (message.data === "true") { 201 | myPeerConnection.onnegotiationneeded = handleNegotiationNeededEvent; 202 | } 203 | } 204 | 205 | function createPeerConnection() { 206 | myPeerConnection = new RTCPeerConnection(peerConnectionConfig); 207 | 208 | // event handlers for the ICE negotiation process 209 | myPeerConnection.onicecandidate = handleICECandidateEvent; 210 | myPeerConnection.ontrack = handleTrackEvent; 211 | 212 | // the following events are optional and could be realized later if needed 213 | // myPeerConnection.onremovetrack = handleRemoveTrackEvent; 214 | // myPeerConnection.oniceconnectionstatechange = handleICEConnectionStateChangeEvent; 215 | // myPeerConnection.onicegatheringstatechange = handleICEGatheringStateChangeEvent; 216 | // myPeerConnection.onsignalingstatechange = handleSignalingStateChangeEvent; 217 | } 218 | // add MediaStream to local video element and to the Peer 219 | function getLocalMediaStream(mediaStream) { 220 | localStream = mediaStream; 221 | localVideo.srcObject = mediaStream; 222 | localStream.getTracks().forEach(track => myPeerConnection.addTrack(track, localStream)); 223 | } 224 | 225 | // handle get media error 226 | function handleGetUserMediaError(error) { 227 | log('navigator.getUserMedia error: ', error); 228 | switch(error.name) { 229 | case "NotFoundError": 230 | alert("Unable to open your call because no camera and/or microphone were found."); 231 | break; 232 | case "SecurityError": 233 | case "PermissionDeniedError": 234 | // Do nothing; this is the same as the user canceling the call. 235 | break; 236 | default: 237 | alert("Error opening your camera and/or microphone: " + error.message); 238 | break; 239 | } 240 | 241 | stop(); 242 | } 243 | 244 | // send ICE candidate to the peer through the server 245 | function handleICECandidateEvent(event) { 246 | if (event.candidate) { 247 | sendToServer({ 248 | from: localUserName, 249 | type: 'ice', 250 | candidate: event.candidate 251 | }); 252 | log('ICE Candidate Event: ICE candidate sent'); 253 | } 254 | } 255 | 256 | function handleTrackEvent(event) { 257 | log('Track Event: set stream to remote video element'); 258 | remoteVideo.srcObject = event.streams[0]; 259 | } 260 | 261 | // WebRTC called handler to begin ICE negotiation 262 | // 1. create a WebRTC offer 263 | // 2. set local media description 264 | // 3. send the description as an offer on media format, resolution, etc 265 | function handleNegotiationNeededEvent() { 266 | myPeerConnection.createOffer().then(function(offer) { 267 | return myPeerConnection.setLocalDescription(offer); 268 | }) 269 | .then(function() { 270 | sendToServer({ 271 | from: localUserName, 272 | type: 'offer', 273 | sdp: myPeerConnection.localDescription 274 | }); 275 | log('Negotiation Needed Event: SDP offer sent'); 276 | }) 277 | .catch(function(reason) { 278 | // an error occurred, so handle the failure to connect 279 | handleErrorMessage('failure to connect error: ', reason); 280 | }); 281 | } 282 | 283 | function handleOfferMessage(message) { 284 | log('Accepting Offer Message'); 285 | log(message); 286 | let desc = new RTCSessionDescription(message.sdp); 287 | //TODO test this 288 | if (desc != null && message.sdp != null) { 289 | log('RTC Signalling state: ' + myPeerConnection.signalingState); 290 | myPeerConnection.setRemoteDescription(desc).then(function () { 291 | log("Set up local media stream"); 292 | return navigator.mediaDevices.getUserMedia(mediaConstraints); 293 | }) 294 | .then(function (stream) { 295 | log("-- Local video stream obtained"); 296 | localStream = stream; 297 | try { 298 | localVideo.srcObject = localStream; 299 | } catch (error) { 300 | localVideo.src = window.URL.createObjectURL(stream); 301 | } 302 | 303 | log("-- Adding stream to the RTCPeerConnection"); 304 | localStream.getTracks().forEach(track => myPeerConnection.addTrack(track, localStream)); 305 | }) 306 | .then(function () { 307 | log("-- Creating answer"); 308 | // Now that we've successfully set the remote description, we need to 309 | // start our stream up locally then create an SDP answer. This SDP 310 | // data describes the local end of our call, including the codec 311 | // information, options agreed upon, and so forth. 312 | return myPeerConnection.createAnswer(); 313 | }) 314 | .then(function (answer) { 315 | log("-- Setting local description after creating answer"); 316 | // We now have our answer, so establish that as the local description. 317 | // This actually configures our end of the call to match the settings 318 | // specified in the SDP. 319 | return myPeerConnection.setLocalDescription(answer); 320 | }) 321 | .then(function () { 322 | log("Sending answer packet back to other peer"); 323 | sendToServer({ 324 | from: localUserName, 325 | type: 'answer', 326 | sdp: myPeerConnection.localDescription 327 | }); 328 | 329 | }) 330 | // .catch(handleGetUserMediaError); 331 | .catch(handleErrorMessage) 332 | } 333 | } 334 | 335 | function handleAnswerMessage(message) { 336 | log("The peer has accepted request"); 337 | 338 | // Configure the remote description, which is the SDP payload 339 | // in our "video-answer" message. 340 | // myPeerConnection.setRemoteDescription(new RTCSessionDescription(message.sdp)).catch(handleErrorMessage); 341 | myPeerConnection.setRemoteDescription(message.sdp).catch(handleErrorMessage); 342 | } 343 | 344 | function handleNewICECandidateMessage(message) { 345 | let candidate = new RTCIceCandidate(message.candidate); 346 | log("Adding received ICE candidate: " + JSON.stringify(candidate)); 347 | myPeerConnection.addIceCandidate(candidate).catch(handleErrorMessage); 348 | } 349 | --------------------------------------------------------------------------------