commit 6b906685012d96acdc143641c04aba8c71b3cd28 Author: Jerko Steiner Date: Thu Mar 31 18:03:39 2016 -0400 Initial commit, working full screen video diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..86c445f --- /dev/null +++ b/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": ["es2015", "react"] +} diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..f569a0c --- /dev/null +++ b/.eslintrc @@ -0,0 +1,212 @@ +{ + "parserOptions": { + "ecmaFeatures": { + "globalReturn": true, + "modules": true, + "jsx": true + } + }, + + "plugins": [ + "react" + ], + + "esnext": true, + + "env": { + "browser": false, + "es6": true, + "amd": true, + "mocha": true, + "node": true + }, + + "globals": { + "document": false, + "escape": false, + "navigator": false, + "unescape": false, + "expect": true, + "localStorage": false, + "window": false, + "jest": true + }, + + "rules": { + "block-scoped-var": 0, + "brace-style": [2, "1tbs", { "allowSingleLine": true }], + "max-len": [2, 80, 4], + "camelcase": 0, + "comma-dangle": 0, + "comma-spacing": [2, { "before": false, "after": true }], + "comma-style": [2, "last"], + "complexity": 0, + "consistent-return": 0, + "consistent-this": 0, + "curly": 0, + "default-case": 0, + "dot-notation": 0, + "eol-last": 2, + "eqeqeq": [2, "allow-null"], + "func-names": 0, + "func-style": [0, "declaration"], + "generator-star-spacing": [2], + "guard-for-in": 0, + "handle-callback-err": [2, "^(err|error|anySpecificError)$" ], + "key-spacing": [2, { "beforeColon": false, "afterColon": true }], + "max-depth": 0, + "max-nested-callbacks": 0, + "max-params": 0, + "max-statements": 0, + "new-cap": [2, { "newIsCap": true, "capIsNew": false }], + "new-parens": 2, + "no-alert": 2, + "no-array-constructor": 2, + "no-bitwise": 0, + "no-caller": 2, + "no-catch-shadow": 0, + "no-cond-assign": 2, + "no-console": 0, + "no-constant-condition": 0, + "no-control-regex": 2, + "no-debugger": 2, + "no-delete-var": 2, + "no-div-regex": 0, + "no-dupe-args": 2, + "no-dupe-keys": 2, + "no-duplicate-case": 2, + "no-else-return": 0, + "no-empty": 0, + "no-empty-character-class": 2, + "no-labels": 2, + "no-eq-null": 0, + "no-eval": 2, + "no-ex-assign": 2, + "no-extend-native": 2, + "no-extra-bind": 2, + "no-extra-boolean-cast": 2, + "no-extra-parens": 0, + "no-extra-semi": 0, + "no-extra-strict": 0, + "no-fallthrough": 2, + "no-floating-decimal": 2, + "no-func-assign": 2, + "no-implied-eval": 2, + "no-inline-comments": 0, + "no-inner-declarations": [2, "functions"], + "no-invalid-regexp": 2, + "no-irregular-whitespace": 2, + "no-iterator": 2, + "no-label-var": 2, + "no-lone-blocks": 2, + "no-lonely-if": 0, + "no-loop-func": 0, + "no-mixed-requires": [0, false], + "no-mixed-spaces-and-tabs": [2, false], + "no-multi-spaces": 2, + "no-multi-str": 2, + "no-multiple-empty-lines": 0, + "no-native-reassign": 2, + "no-negated-in-lhs": 2, + "no-nested-ternary": 0, + "no-new": 0, + "no-new-func": 2, + "no-new-object": 2, + "no-new-require": 2, + "no-new-wrappers": 2, + "no-obj-calls": 2, + "no-octal": 2, + "no-octal-escape": 2, + "no-path-concat": 0, + "no-plusplus": 0, + "no-process-env": 0, + "no-process-exit": 0, + "no-proto": 2, + "no-redeclare": 2, + "no-regex-spaces": 2, + "no-reserved-keys": 0, + "no-restricted-modules": 0, + "no-return-assign": 2, + "no-script-url": 2, + "no-self-compare": 2, + "no-sequences": 2, + "no-shadow": 0, + "no-shadow-restricted-names": 2, + "no-spaced-func": 2, + "no-sparse-arrays": 2, + "no-sync": 0, + "no-ternary": 0, + "no-throw-literal": 2, + "no-trailing-spaces": 2, + "no-undef": 2, + "no-undef-init": 2, + "no-undefined": 0, + "no-underscore-dangle": 0, + "no-unreachable": 2, + "no-unused-expressions": 0, + "no-unused-vars": [2, { "vars": "all", "args": "none" }], + "no-use-before-define": 0, + "no-var": 0, + "no-void": 0, + "no-warning-comments": [0, { "terms": ["todo", "fixme", "xxx"], "location": "start" }], + "no-with": 2, + "one-var": 0, + "operator-assignment": [0, "always"], + "padded-blocks": 0, + "quote-props": 0, + "quotes": [2, "single", "avoid-escape"], + "radix": 2, + "semi": [2, "always"], + "semi-spacing": 0, + "sort-vars": 0, + "keyword-spacing": ["error", {"before": true, "after": true, "overrides": {}}], + "space-before-blocks": [2, "always"], + "space-before-function-paren": [2, "never"], + "space-in-brackets": 0, + "space-in-parens": [2, "never"], + "space-infix-ops": 2, + "space-unary-ops": [2, { "words": true, "nonwords": false }], + "spaced-comment": [2, "always"], + "strict": 0, + "use-isnan": 2, + "valid-jsdoc": 0, + "valid-typeof": 2, + "vars-on-top": 0, + "wrap-iife": [2, "any"], + "wrap-regex": 0, + "yoda": [2, "never"], + + // "react/display-name": 1, + "react/forbid-prop-types": 1, + "react/jsx-boolean-value": 1, + "react/jsx-closing-bracket-location": 1, + "react/jsx-curly-spacing": 1, + "react/jsx-handler-names": 1, + "react/jsx-indent-props": [1, 2], + "react/jsx-key": 1, + "react/jsx-max-props-per-line": 0, + "react/jsx-no-bind": 1, + "react/jsx-no-duplicate-props": 1, + "react/jsx-no-literals": 1, + "react/jsx-no-undef": 1, + "react/jsx-pascal-case": 0, + "jsx-quotes": 1, + "react/sort-prop-types": 1, + "react/jsx-sort-props": 1, + "react/jsx-uses-react": 1, + "react/jsx-uses-vars": 1, + "react/no-danger": 1, + "react/no-did-mount-set-state": 1, + "react/no-did-update-set-state": 1, + "react/no-direct-mutation-state": 1, + "react/no-set-state": 1, + "react/no-unknown-property": 1, + "react/prefer-es6-class": 0, + // "react/prop-types": 1, + "react/react-in-jsx-scope": 1, + "react/require-extension": 1, + "react/self-closing-comp": 1, + "react/sort-comp": 1, + "react/wrap-multilines": 1 + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..796cf43 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.swp +*.swo +node_modules/ +config.js +coverage/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..0cfb6ee --- /dev/null +++ b/.travis.yml @@ -0,0 +1,19 @@ +language: node_js +node_js: + - "5.1.0" +script: make +env: + - CXX=g++-4.8 +notifications: + email: false +addons: + apt: + sources: + - ubuntu-toolchain-r-test + packages: + - libx11-dev + - zlib1g-dev + - libpng12-dev + - libxtst-dev + - g++-4.8 + - gcc-4.8 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..77afa57 --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +export PATH := node_modules/.bin:$(PATH) +SHELL=/bin/bash + +.PHONY: build +build: clean + + eslint src/ + + jest --coverage + + mkdir -p dist/js dist/less + + browserify -t babelify ./src/js/index.js -o ./dist/js/index.js + + lessc ./src/less/main.less ./dist/less/main.css + + cp ./src/index.js ./dist/index.js + cp -r ./src/server ./dist/server + cp -r ./src/less/fonts ./dist/less/fonts + cp -r ./src/views ./dist/views + cp -r ./src/res ./dist/res + +.PHONY: test +test: + + jest + +.PHONY: run +run: + + node ./src/index.js + +.PHONY: clean +clean: + + rm -rf dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea4fbe7 --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Remote Control Server + +[![Build Status](https://travis-ci.org/jeremija/remote-control-server.svg?branch=master)](https://travis-ci.org/jeremija/remote-control-server) + +Remote control your PC from your web browser on your other PC or mobile device. + +Supports mouse movements, scrolling, clicking and keyboard input. + +Work in progress. + + + + + +# Install & Run + +Install from npm: + +```bash +npm install -g remote-control-server +remote-control-server +``` + +or use from git source: + +```bash +git clone https://github.com/jeremija/remote-control-server.git +cd node-mobile-remote +npm install +npm start +``` + +On your other machine or mobile device open the url: + +```bash +http://192.168.0.10:3000 +``` + +Replace `192.168.0.10` with the LAN IP address of your server. + +# Note + +This package requires [robotjs](https://www.npmjs.com/package/robotjs) so make +sure you have the required prerequisites installed for compiling that package. + +# license + +MIT diff --git a/package.json b/package.json new file mode 100644 index 0000000..70edf7f --- /dev/null +++ b/package.json @@ -0,0 +1,54 @@ +{ + "name": "remote-control-server", + "version": "1.3.2", + "description": "Remote control your PC from your web browser on your other PC or mobile device", + "repository": "https://github.com/jeremija/remote-control-server", + "main": "src/index.js", + "bin": { + "remote-control-server": "./dist/index.js" + }, + "scripts": { + "start": "node src/index.js", + "test": "jest --coverage", + "testify": "jest --watch", + "lint": "eslint ./index.js ./src/js" + }, + "author": "", + "license": "MIT", + "dependencies": { + "babel-jest": "^10.0.1", + "babel-preset-es2015": "^6.6.0", + "babel-preset-react": "^6.5.0", + "babelify": "^7.2.0", + "bluebird": "^3.3.4", + "express": "^4.13.3", + "flux": "^2.1.1", + "jade": "^1.11.0", + "react": "^0.14.8", + "react-dom": "^0.14.8", + "simple-peer": "^6.0.3", + "socket.io": "^1.3.7", + "socket.io-client": "^1.3.7", + "underscore": "^1.8.3" + }, + "devDependencies": { + "browserify-middleware": "^7.0.0", + "eslint": "^2.5.3", + "eslint-plugin-react": "^4.2.3", + "jest-cli": "^0.10.0", + "less-middleware": "^2.0.1", + "react-addons-test-utils": "^0.14.8" + }, + "jest": { + "scriptPreprocessor": "/node_modules/babel-jest", + "unmockedModulePathPatterns": [ + "/node_modules/react", + "/node_modules/react-dom", + "/node_modules/react-addons-test-utils", + "/node_modules/fbjs" + ], + "modulePathIgnorePatterns": [ + "/node_modules/" + ] + } +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..972a2d5 --- /dev/null +++ b/src/index.js @@ -0,0 +1,49 @@ +#!/usr/bin/env node +'use strict'; +if (!process.env.DEBUG) { + process.env.DEBUG = 'video-server:*'; +} + +const express = require('express'); +const app = express(); +const http = require('http').Server(app); +const io = require('socket.io')(http); +const path = require('path'); +const os = require('os'); + +const handleSocket = require('./server/socket.js'); + +app.set('view engine', 'jade'); +app.set('views', path.join(__dirname, 'views')); + +app.use('/res', express.static(path.join(__dirname, 'res'))); + +if (path.basename(__dirname) === 'dist') { + app.use('/js', express.static(path.join(__dirname, 'js'))); + app.use('/less', express.static(path.join(__dirname, 'less'))); +} else { + const browserify = require('browserify-middleware'); + const less = require('less-middleware'); + browserify.settings({ + transform: ['babelify'] + }); + + const tempDir = path.join(os.tmpDir(), 'node-mpv-css-cache'); + app.use('/js', browserify(path.join(__dirname, './js'))); + app.use('/less', less(path.join(__dirname, './less'), { dest: tempDir})); + app.use('/less', express.static(tempDir)); + app.use('/less/fonts', express.static( + path.join(__dirname, './less/fonts'))); +} + +app.get('/', (req, res) => res.render('index')); + +io.on('connection', socket => handleSocket(socket, io)); + +let port = process.env.PORT || 3000; +let ifaces = os.networkInterfaces(); +http.listen(port, function() { + Object.keys(ifaces).forEach(ifname => + ifaces[ifname].forEach(iface => + console.log('listening on', iface.address, 'and port', port))); +}); diff --git a/src/js/browser/createObjectURL.js b/src/js/browser/createObjectURL.js new file mode 100644 index 0000000..7c5380f --- /dev/null +++ b/src/js/browser/createObjectURL.js @@ -0,0 +1,2 @@ +'use strict'; +module.exports = object => window.URL.createObjectURL(object); diff --git a/src/js/browser/getUserMedia.js b/src/js/browser/getUserMedia.js new file mode 100644 index 0000000..abbe853 --- /dev/null +++ b/src/js/browser/getUserMedia.js @@ -0,0 +1,16 @@ +'use strict'; +const navigator = require('../browser/navigator.js'); +const Promise = require('bluebird'); + +function getUserMedia(constraints) { + if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { + return navigator.mediaDevices.getUserMedia(constraints); + } + + return new Promise((resolve, reject) => { + const getMedia = navigator.getUserMedia || navigator.webkitGetUserMedia; + getMedia.call(navigator, constraints, resolve, reject); + }); +} + +module.exports = getUserMedia; diff --git a/src/js/browser/navigator.js b/src/js/browser/navigator.js new file mode 100644 index 0000000..685ac48 --- /dev/null +++ b/src/js/browser/navigator.js @@ -0,0 +1,2 @@ +'use strict'; +module.exports = window.navigator; diff --git a/src/js/components/__tests__/app-test.js b/src/js/components/__tests__/app-test.js new file mode 100644 index 0000000..b059486 --- /dev/null +++ b/src/js/components/__tests__/app-test.js @@ -0,0 +1,60 @@ +jest.dontMock('../app.js'); +jest.dontMock('underscore'); + +const React = require('react'); +const ReactDOM = require('react-dom'); +const TestUtils = require('react-addons-test-utils'); + +const App = require('../app.js'); +const activeStore = require('../../store/activeStore.js'); +const dispatcher = require('../../dispatcher/dispatcher.js'); +const streamStore = require('../../store/streamStore.js'); + +describe('app', () => { + + beforeEach(() => { + dispatcher.dispatch.mockClear(); + }); + + function render(active) { + streamStore.getStreams.mockReturnValue({ + user1: { stream: 1 }, + user2: { stream: 2 } + }); + let component = TestUtils.renderIntoDocument(
); + return ReactDOM.findDOMNode(component).children[0]; + } + + it('should render div.app', () => { + let node = render(); + expect(node.tagName).toBe('DIV'); + expect(node.className).toBe('app'); + }); + + it('should have rendered two videos', () => { + let node = render(); + + expect(node.querySelectorAll('video').length).toBe(2); + }); + + it('should mark .active video', () => { + activeStore.getActive.mockReturnValue('user1'); + activeStore.isActive.mockImplementation(test => test === 'user1'); + + let node = render(); + expect(node.querySelectorAll('.video-container').length).toBe(2); + expect(node.querySelectorAll('.video-container.active').length).toBe(1); + }); + + it('should dispatch mark-active on video click', () => { + let node = render(); + + TestUtils.Simulate.click(node.querySelectorAll('video')[1]); + + expect(dispatcher.dispatch.mock.calls).toEqual([[{ + type: 'mark-active', + userId: 'user2' + }]]); + }); + +}); diff --git a/src/js/components/app.js b/src/js/components/app.js new file mode 100644 index 0000000..19d3887 --- /dev/null +++ b/src/js/components/app.js @@ -0,0 +1,42 @@ +const React = require('react'); +const _ = require('underscore'); +const activeStore = require('../store/activeStore.js'); +const createObjectURL = require('../browser/createObjectURL'); +const dispatcher = require('../dispatcher/dispatcher.js'); +const streamStore = require('../store/streamStore.js'); + +function app() { + let streams = streamStore.getStreams(); + + function play(event) { + event.target.play(); + } + + let videos = _.map(streams, (stream, userId) => { + let url = createObjectURL(stream); + + function markActive() { + dispatcher.dispatch({ + type: 'mark-active', + userId + }); + } + + let className = 'video-container'; + className += activeStore.isActive(userId) ? ' active' : ''; + + return ( +
+
+ ); + }); + + return (
+
+ {videos} +
+
); +} + +module.exports = app; diff --git a/src/js/dispatcher/dispatcher.js b/src/js/dispatcher/dispatcher.js new file mode 100644 index 0000000..4a8678f --- /dev/null +++ b/src/js/dispatcher/dispatcher.js @@ -0,0 +1,5 @@ +const Dispatcher = require('flux').Dispatcher; + +const dispatcher = new Dispatcher(); + +module.exports = dispatcher; diff --git a/src/js/index.js b/src/js/index.js new file mode 100644 index 0000000..b2ab49d --- /dev/null +++ b/src/js/index.js @@ -0,0 +1,39 @@ +if (window.localStorage && !window.localStorage.debug) { + window.localStorage.debug = 'video-client:*'; +} + +const React = require('react'); +const ReactDom = require('react-dom'); + +const App = require('./components/app.js'); +const handshake = require('./peer/handshake.js'); +const debug = require('debug')('video-client:index'); +const getUserMedia = require('./browser/getUserMedia.js'); +const socket = require('./socket.js'); +const activeStore = require('./store/activeStore.js'); +const streamStore = require('./store/streamStore.js'); + +function render() { + ReactDom.render(, document.querySelector('#container')); +} + +streamStore.addListener(() => () => { + debug('streamStore - change'); + debug(streamStore.getStreams()); +}); +streamStore.addListener(render); +activeStore.addListener(render); + +render(); + +socket.once('connect', () => { + debug('socket connected'); + getUserMedia({ video: true, audio: true }) + .then(stream => { + debug('forwarding stream to handshake'); + handshake.init(socket, 'test', stream); + }) + .catch(err => { + debug('error getting media: %s %s', err.name, err.message); + }); +}); diff --git a/src/js/peer/Peer.js b/src/js/peer/Peer.js new file mode 100644 index 0000000..1ff4034 --- /dev/null +++ b/src/js/peer/Peer.js @@ -0,0 +1,8 @@ +'use strict'; +const Peer = require('simple-peer'); + +function init(opts) { + return Peer(opts); +} + +module.exports = { init }; diff --git a/src/js/peer/__tests__/handshake-test.js b/src/js/peer/__tests__/handshake-test.js new file mode 100644 index 0000000..596be02 --- /dev/null +++ b/src/js/peer/__tests__/handshake-test.js @@ -0,0 +1,174 @@ +jest.dontMock('../handshake.js'); +jest.dontMock('events'); +jest.dontMock('debug'); +jest.dontMock('underscore'); + +const dispatcher = require('../../dispatcher/dispatcher.js'); +const handshake = require('../handshake.js'); +const Peer = require('../Peer.js'); +const EventEmitter = require('events').EventEmitter; + +describe('handshake', () => { + + let socket, peers; + beforeEach(() => { + socket = new EventEmitter(); + socket.id = 'a'; + peers = []; + + Peer.init = jest.genMockFunction().mockImplementation(() => { + let peer = new EventEmitter(); + peer.destroy = jest.genMockFunction(); + peer.signal = jest.genMockFunction(); + peers.push(peer); + return peer; + }); + + dispatcher.dispatch.mockClear(); + }); + + describe('socket events', () => { + + describe('users', () => { + + it('add a peer for each new user and destroy peers for missing', () => { + handshake.init(socket, 'bla'); + + // given + let payload = { + users: [{ id: 'a'}, { id: 'b' }], + initiator: '/#a', + }; + socket.emit('users', payload); + expect(peers.length).toBe(2); + + // when + payload = { + users: [{ id: 'a'}, { id: 'c' }], + initiator: '/#c', + }; + socket.emit('users', payload); + + // then + expect(peers.length).toBe(3); + expect(peers[0].destroy.mock.calls.length).toBe(0); + expect(peers[1].destroy.mock.calls.length).toBe(1); + expect(peers[2].destroy.mock.calls.length).toBe(0); + }); + + }); + + describe('signal', () => { + let data; + beforeEach(() => { + data = {}; + handshake.init(socket, 'bla'); + socket.emit('users', { + initiator: '#/a', + users: [{ id: 'a' }] + }); + }); + + it('should forward signal to peer', () => { + socket.emit('signal', { + userId: 'a', + data + }); + + expect(peers.length).toBe(1); + expect(peers[0].signal.mock.calls.length).toBe(1); + }); + + it('does nothing if no peer', () => { + socket.emit('signal', { + userId: 'b', + data + }); + + expect(peers.length).toBe(1); + expect(peers[0].signal.mock.calls.length).toBe(0); + }); + + }); + + }); + + describe('peer events', () => { + + let peer; + beforeEach(() => { + let ready = false; + socket.once('ready', () => { ready = true; }); + + handshake.init(socket, 'bla'); + + socket.emit('users', { + users: [{ id: 'a' }], + initiator: '/#a' + }); + expect(peers.length).toBe(1); + peer = peers[0]; + + expect(ready).toBeDefined(); + }); + + describe('error', () => { + + it('destroys peer', () => { + peer.emit('error', new Error('bla')); + expect(peer.destroy.mock.calls.length).toBe(1); + }); + + }); + + describe('signal', () => { + + it('emits socket signal with user id', done => { + let signal = { bla: 'bla' }; + + socket.once('signal', payload => { + expect(payload.userId).toEqual('a'); + expect(payload.signal).toBe(signal); + done(); + }); + + peer.emit('signal', signal); + }); + + }); + + describe('stream', () => { + + it('adds a stream to streamStore', () => { + expect(dispatcher.dispatch.mock.calls.length).toBe(0); + + let stream = {}; + peer.emit('stream', stream); + + expect(dispatcher.dispatch.mock.calls.length).toBe(1); + expect(dispatcher.dispatch.mock.calls).toEqual([[{ + type: 'add-stream', + userId: 'a', + stream + }]]); + }); + + }); + + describe('close', () => { + + it('removes stream from streamStore', () => { + peer.emit('close'); + + expect(dispatcher.dispatch.mock.calls.length).toBe(1); + expect(dispatcher.dispatch.mock.calls).toEqual([[{ + type: 'remove-stream', + userId: 'a' + }]]); + }); + + }); + + }); + +}); diff --git a/src/js/peer/handshake.js b/src/js/peer/handshake.js new file mode 100644 index 0000000..a4cb18a --- /dev/null +++ b/src/js/peer/handshake.js @@ -0,0 +1,91 @@ +'use strict'; +const Peer = require('./Peer.js'); +const debug = require('debug')('video-client:peer'); +const dispatcher = require('../dispatcher/dispatcher.js'); +const _ = require('underscore'); + +function init(socket, roomName, stream) { + let peers = {}; + + function createPeer(user, initiator) { + debug('create peer: %s', user.id); + + let peer = peers[user.id] = Peer.init({ + initiator: '/#' + socket.id === initiator, + stream + }); + + peer.once('error', err => { + debug('peer: %s, error %s', user.id, err.stack); + destroyPeer(user.id); + }); + + peer.on('signal', signal => { + debug('peer: %s, signal: %o', user.id, signal); + + let payload = { userId: user.id, signal }; + socket.emit('signal', payload); + }); + + peer.once('connect', () => { + debug('peer: %s, connect', user.id); + }); + + peer.on('stream', stream => { + debug('peer: %s, stream', user.id); + dispatcher.dispatch({ + type: 'add-stream', + userId: user.id, + stream + }); + }); + + peer.once('close', () => { + debug('peer: %s, close', user.id); + dispatcher.dispatch({ + type: 'remove-stream', + userId: user.id + }); + delete peers[user.id]; + }); + } + + function destroyPeer(userId) { + debug('destroy peer: %s', userId); + let peer = peers[userId]; + if (!peer) return debug('peer: %s peer not found', userId); + peer.destroy(); + delete peers[userId]; + } + + socket.on('signal', payload => { + let peer = peers[payload.userId]; + let signal = payload.signal; + // debug('socket signal, userId: %s, signal: %o', payload.userId, signal); + + if (!peer) return debug('user: %s, no peer found', payload.userId); + peer.signal(signal); + }); + + + socket.on('users', payload => { + let { initiator, users } = payload; + debug('socket users: %o', users); + + users + .filter(user => !peers[user.id] && user.id !== '/#' + socket.id) + .forEach(user => createPeer(user, initiator)); + + let newUsersMap = _.indexBy(users, 'id'); + _.chain(peers) + .map((peer, id) => id) + .filter(id => !newUsersMap[id]) + .each(destroyPeer); + }); + + debug('socket.id: %s', socket.id); + debug('emit ready for room: %s', roomName); + socket.emit('ready', roomName); +} + +module.exports = { init }; diff --git a/src/js/socket.js b/src/js/socket.js new file mode 100644 index 0000000..2c0ac54 --- /dev/null +++ b/src/js/socket.js @@ -0,0 +1,5 @@ +let SocketIOClient = require('socket.io-client'); + +let socket = new SocketIOClient(); + +module.exports = socket; diff --git a/src/js/store/__tests__/activeStore-test.js b/src/js/store/__tests__/activeStore-test.js new file mode 100644 index 0000000..40252e5 --- /dev/null +++ b/src/js/store/__tests__/activeStore-test.js @@ -0,0 +1,35 @@ +jest.dontMock('../activeStore.js'); +jest.dontMock('debug'); + +const dispatcher = require('../../dispatcher/dispatcher.js'); +const activeStore = require('../activeStore.js'); + +describe('activeStore', () => { + + let handleAction = dispatcher.register.mock.calls[0][0]; + let onChange = jest.genMockFunction(); + + beforeEach(() => { + onChange.mockClear(); + activeStore.addListener(onChange); + }); + afterEach(() => activeStore.removeListener(onChange)); + + describe('mark-active', () => { + + it('should mark id as active', () => { + expect(activeStore.getActive()).not.toBeDefined(); + expect(activeStore.isActive('user1')).toBe(false); + expect(onChange.mock.calls.length).toBe(0); + + handleAction({ type: 'mark-active', userId: 'user1' }); + + expect(activeStore.getActive()).toBe('user1'); + expect(activeStore.isActive('user1')).toBe(true); + + expect(onChange.mock.calls.length).toBe(1); + }); + + }); + +}); diff --git a/src/js/store/__tests__/streamStore-test.js b/src/js/store/__tests__/streamStore-test.js new file mode 100644 index 0000000..75cafc1 --- /dev/null +++ b/src/js/store/__tests__/streamStore-test.js @@ -0,0 +1,57 @@ +jest.dontMock('../streamStore.js'); +jest.dontMock('debug'); + +const dispatcher = require('../../dispatcher/dispatcher.js'); +const streamStore = require('../streamStore.js'); + +describe('streamStore', () => { + + let handleAction = dispatcher.register.mock.calls[0][0]; + let onChange = jest.genMockFunction(); + + beforeEach(() => { + onChange.mockClear(); + streamStore.addListener(onChange); + }); + afterEach(() => streamStore.removeListener(onChange)); + + describe('add-stream and remove-stream', () => { + + it('should add a stream', () => { + let stream = {}; + + handleAction({ type: 'add-stream', userId: 'user1', stream }); + + expect(streamStore.getStream('user1')).toBe(stream); + expect(onChange.mock.calls.length).toEqual(1); + }); + + it('should add a stream multiple times', () => { + let stream1 = {}; + let stream2 = {}; + + handleAction({ type: 'add-stream', userId: 'user1', stream: stream1 }); + handleAction({ type: 'add-stream', userId: 'user2', stream: stream2 }); + + expect(streamStore.getStream('user1')).toBe(stream1); + expect(streamStore.getStream('user2')).toBe(stream2); + expect(streamStore.getStreams()).toEqual({ + user1: stream1, + user2: stream2 + }); + expect(onChange.mock.calls.length).toEqual(2); + }); + + it('should remove a stream', () => { + let stream = {}; + + handleAction({ type: 'add-stream', userId: 'user1', stream }); + handleAction({ type: 'remove-stream', userId: 'user1' }); + + expect(streamStore.getStream('user1')).not.toBeDefined(); + expect(onChange.mock.calls.length).toEqual(2); + }); + + }); + +}); diff --git a/src/js/store/activeStore.js b/src/js/store/activeStore.js new file mode 100644 index 0000000..50f812d --- /dev/null +++ b/src/js/store/activeStore.js @@ -0,0 +1,40 @@ +'use strict'; +const EventEmitter = require('events'); +const debug = require('debug')('video-client:activeStore'); +const dispatcher = require('../dispatcher/dispatcher.js'); + +const emitter = new EventEmitter(); +const addListener = cb => emitter.on('change', cb); +const removeListener = cb => emitter.removeListener('change', cb); + +let active; + +const handlers = { + 'mark-active': ({ userId }) => { + debug('mark-active, userId: %s', userId); + active = userId; + } +}; + +const dispatcherIndex = dispatcher.register(action => { + let handle = handlers[action.type]; + if (!handle) return; + handle(action); + emitter.emit('change'); +}); + +function getActive() { + return active; +} + +function isActive(test) { + return active === test; +} + +module.exports = { + dispatcherIndex, + addListener, + removeListener, + getActive, + isActive +}; diff --git a/src/js/store/streamStore.js b/src/js/store/streamStore.js new file mode 100644 index 0000000..4951cdf --- /dev/null +++ b/src/js/store/streamStore.js @@ -0,0 +1,44 @@ +'use strict'; +const EventEmitter = require('events'); +const debug = require('debug')('video-client:streamStore'); +const dispatcher = require('../dispatcher/dispatcher.js'); + +const emitter = new EventEmitter(); +const addListener = cb => emitter.on('change', cb); +const removeListener = cb => emitter.removeListener('change', cb); + +const streams = {}; + +const handlers = { + 'add-stream': ({ userId, stream }) => { + debug('add-stream, user: %s', userId); + streams[userId] = stream; + }, + 'remove-stream': ({ userId }) => { + debug('remove-stream, user: %s', userId); + delete streams[userId]; + } +}; + +const dispatcherIndex = dispatcher.register(action => { + let handle = handlers[action.type]; + if (!handle) return; + handle(action); + emitter.emit('change'); +}); + +function getStream(userId) { + return streams[userId]; +} + +function getStreams() { + return streams; +} + +module.exports = { + dispatcherIndex, + addListener, + removeListener, + getStream, + getStreams +}; diff --git a/src/less/fonts.less b/src/less/fonts.less new file mode 100644 index 0000000..afa9c58 --- /dev/null +++ b/src/less/fonts.less @@ -0,0 +1,70 @@ +@font-face { + font-family: 'icons'; + src: url('./fonts/icons.eot?37351711'); + src: url('./fonts/icons.eot?37351711#iefix') format('embedded-opentype'), + url('./fonts/icons.woff?37351711') format('woff'), + url('./fonts/icons.ttf?37351711') format('truetype'), + url('./fonts/icons.svg?37351711#icons') format('svg'); + font-weight: normal; + font-style: normal; +} +// Chrome hack: SVG is rendered more smooth in Windozze. 100% magic, uncomment if you need it. +// Note, that will break hinting! In other OS-es font will be not as sharp as it could be +// @media screen and (-webkit-min-device-pixel-ratio:0) { +// @font-face { +// font-family: 'icons'; +// src: url('../font/icons.svg?37351711#icons') format('svg'); +// } +// } + +[class^="icon-"]:before, [class*=" icon-"]:before { + font-family: "icons"; + font-style: normal; + font-weight: normal; + speak: none; + + display: inline-block; + text-decoration: inherit; + width: 1em; + margin-right: .2em; + text-align: center; + /* opacity: .8; */ + + /* For safety - reset parent styles, that can break glyph codes*/ + font-variant: normal; + text-transform: none; + + /* fix buttons height, for twitter bootstrap */ + line-height: 1em; + + /* Animation center compensation - margins should be symmetric */ + /* remove if not needed */ + margin-left: .2em; + + /* you can be more comfortable with increased icons size */ + /* font-size: 120%; */ + + /* Font smoothing. That was taken from TWBS */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + /* Uncomment for 3D effect */ + /* text-shadow: 1px 1px 1px rgba(127, 127, 127, 0.3); */ +} + +.icon-down-open-big:before { content: '\e800'; } /* '' */ +.icon-down-open:before { content: '\e801'; } /* '' */ +.icon-mouse:before { content: '\e802'; } /* '' */ +.icon-keyboard:before { content: '\e803'; } /* '' */ +.icon-left-open:before { content: '\e804'; } /* '' */ +.icon-right-open:before { content: '\e805'; } /* '' */ +.icon-up-open:before { content: '\e806'; } /* '' */ +.icon-arrows:before { content: '\e807'; } /* '' */ +.icon-up-hand:before { content: '\e808'; } /* '' */ +.icon-check:before { content: '\e80b'; } /* '' */ +.icon-cancel:before { content: '\e80c'; } /* '' */ +.icon-level-up:before { content: '\e80d'; } /* '' */ +.icon-login:before { content: '\e80e'; } /* '' */ +.icon-left-open-big:before { content: '\e81d'; } /* '' */ +.icon-right-open-big:before { content: '\e81e'; } /* '' */ +.icon-up-open-big:before { content: '\e81f'; } /* '' */ diff --git a/src/less/fonts/icons.eot b/src/less/fonts/icons.eot new file mode 100644 index 0000000..cd06366 Binary files /dev/null and b/src/less/fonts/icons.eot differ diff --git a/src/less/fonts/icons.svg b/src/less/fonts/icons.svg new file mode 100644 index 0000000..e60e02b --- /dev/null +++ b/src/less/fonts/icons.svg @@ -0,0 +1,27 @@ + + + +Copyright (C) 2015 by original authors @ fontello.com + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/less/fonts/icons.ttf b/src/less/fonts/icons.ttf new file mode 100644 index 0000000..f17209b Binary files /dev/null and b/src/less/fonts/icons.ttf differ diff --git a/src/less/fonts/icons.woff b/src/less/fonts/icons.woff new file mode 100644 index 0000000..a640cde Binary files /dev/null and b/src/less/fonts/icons.woff differ diff --git a/src/less/main.less b/src/less/main.less new file mode 100644 index 0000000..83aac39 --- /dev/null +++ b/src/less/main.less @@ -0,0 +1,72 @@ +@import "fonts.less"; + +@color-bg: #086788; +@color-fg: #07A0C3; +// @color-btn: #F0C808; +@color-btn: #FFF1D0; +@color-active: #F0C808; +@icon-size: 48px; + +* { + box-sizing: border-box; +} + +html, body { + width: 100%; + height: 100%; +} + +body { + background-color: @color-bg; + color: @color-fg; + margin: 0 0; + font-family: sans-serif; +} + +#container { + width: 100%; + height: 100%; +} + +.app { + width: 100%; + height: 100%; + position: relative; + + .videos { + position: absolute; + height: 100px; + bottom: 15px; + right: 0px; + text-align: right; + + .video-container { + display: inline-block; + margin-right: 10px; + width: 150px; + height: 100%; + z-index: 2; + + video { + pointer: cursor; + object-fit: cover; + width: 100%; + height: 100%; + } + } + + .video-container.active { + position: fixed; + width: 100%; + left: 0; + right: 0; + top: 0; + bottom: 0; + z-index: 1; + + video { + pointer: inherit; + } + } + } +} diff --git a/src/res/icon.png b/src/res/icon.png new file mode 100644 index 0000000..e77d385 Binary files /dev/null and b/src/res/icon.png differ diff --git a/src/server/__tests__/socket-test.js b/src/server/__tests__/socket-test.js new file mode 100644 index 0000000..efc13bf --- /dev/null +++ b/src/server/__tests__/socket-test.js @@ -0,0 +1,114 @@ +'use strict'; +jest.dontMock('../socket.js'); +jest.dontMock('events'); +jest.dontMock('debug'); +jest.dontMock('underscore'); + +const EventEmitter = require('events').EventEmitter; +const handleSocket = require('../socket.js'); + +describe('socket', () => { + + let socket, io, rooms; + beforeEach(() => { + socket = new EventEmitter(); + socket.id = '/#socket0'; + socket.join = jest.genMockFunction(); + socket.leave = jest.genMockFunction(); + rooms = {}; + + io = {}; + io.in = io.to = jest.genMockFunction().mockImplementation(room => { + return (rooms[room] = rooms[room] || { + emit: jest.genMockFunction() + }); + }); + + io.sockets = { + adapter: { + rooms: { + room1: { + '/#socket0': true + }, + room2: { + '/#socket0': true + }, + room3: { + sockets: { + '/#socket0': true, + '/#socket1': true, + '/#socket2': true + } + } + } + } + }; + + socket.leave = jest.genMockFunction(); + socket.join = jest.genMockFunction(); + }); + + it('should be a function', () => { + expect(typeof handleSocket).toBe('function'); + }); + + describe('socket events', () => { + + beforeEach(() => handleSocket(socket, io)); + + describe('signal', () => { + + it('should broadcast signal to specific user', () => { + let signal = { type: 'signal' }; + + socket.emit('signal', { userId: 'a', signal }); + + expect(io.to.mock.calls).toEqual([[ 'a' ]]); + expect(io.to('a').emit.mock.calls).toEqual([[ + 'signal', { + userId: '/#socket0', + signal + } + ]]); + }); + + }); + + describe('ready', () => { + + it('should call socket.leave if socket.room', () => { + socket.room = 'room1'; + socket.emit('ready', 'room2'); + + expect(socket.leave.mock.calls).toEqual([[ 'room1' ]]); + expect(socket.join.mock.calls).toEqual([[ 'room2' ]]); + }); + + it('should call socket.join to room', () => { + socket.emit('ready', 'room3'); + expect(socket.join.mock.calls).toEqual([[ 'room3' ]]); + }); + + it('should emit users', () => { + socket.emit('ready', 'room3'); + + expect(io.to.mock.calls).toEqual([[ 'room3' ]]); + expect(io.to('room3').emit.mock.calls).toEqual([[ + 'users', { + initiator: '/#socket0', + users: [{ + id: '/#socket0', + }, { + id: '/#socket1', + }, { + id: '/#socket2' + }] + } + ]]); + }); + + }); + + }); + +}); diff --git a/src/server/socket.js b/src/server/socket.js new file mode 100644 index 0000000..7ae706d --- /dev/null +++ b/src/server/socket.js @@ -0,0 +1,36 @@ +'use strict'; +const debug = require('debug')('video-server:socket'); +const _ = require('underscore'); + +module.exports = function(socket, io) { + + socket.on('signal', payload => { + debug('signal: %s, payload: %o', socket.id, payload); + io.to(payload.userId).emit('signal', { + userId: socket.id, + signal: payload.signal + }); + }); + + socket.on('ready', roomName => { + debug('ready: %s, room: %s', socket.id, roomName); + if (socket.room) socket.leave(socket.room); + socket.room = roomName; + socket.join(roomName); + socket.room = roomName; + + let users = getUsers(roomName); + debug('ready: %s, room: %s, users: %o', socket.id, roomName, users); + io.to(roomName).emit('users', { + initiator: socket.id, + users + }); + }); + + function getUsers(roomName) { + return _.map(io.sockets.adapter.rooms[roomName].sockets, (_, id) => { + return { id }; + }); + } + +}; diff --git a/src/views/index.jade b/src/views/index.jade new file mode 100644 index 0000000..20effc4 --- /dev/null +++ b/src/views/index.jade @@ -0,0 +1,16 @@ +doctype html +html + head + title Remote Control + meta(name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no") + meta(name="mobile-web-app-capable" content="yes") + meta(name="apple-mobile-web-app-capable" content="yes") + link(rel="apple-touch-icon" href="res/icon.png") + link(rel="icon" sizes="256x256" href="res/icon.png") + link(rel="stylesheet" type="text/css" href="less/main.css") + + body + + div#container + + script(src='js/index.js')