Initial commit, working full screen video

This commit is contained in:
Jerko Steiner 2016-03-31 18:03:39 -04:00
commit 6b90668501
33 changed files with 1381 additions and 0 deletions

3
.babelrc Normal file
View File

@ -0,0 +1,3 @@
{
"presets": ["es2015", "react"]
}

212
.eslintrc Normal file
View File

@ -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
}
}

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
*.swp
*.swo
node_modules/
config.js
coverage/

19
.travis.yml Normal file
View File

@ -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

36
Makefile Normal file
View File

@ -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/

48
README.md Normal file
View File

@ -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.
<img src="http://i.imgur.com/38MzUIg.png" width="400px">
<img src="http://i.imgur.com/cn1IUK8.png" width="400px">
<img src="http://i.imgur.com/xtpgXoG.png" width="400px">
# 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

54
package.json Normal file
View File

@ -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": "<rootDir>/node_modules/babel-jest",
"unmockedModulePathPatterns": [
"<rootDir>/node_modules/react",
"<rootDir>/node_modules/react-dom",
"<rootDir>/node_modules/react-addons-test-utils",
"<rootDir>/node_modules/fbjs"
],
"modulePathIgnorePatterns": [
"<rootDir>/node_modules/"
]
}
}

49
src/index.js Normal file
View File

@ -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)));
});

View File

@ -0,0 +1,2 @@
'use strict';
module.exports = object => window.URL.createObjectURL(object);

View File

@ -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;

View File

@ -0,0 +1,2 @@
'use strict';
module.exports = window.navigator;

View File

@ -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(<div><App /></div>);
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'
}]]);
});
});

42
src/js/components/app.js Normal file
View File

@ -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 (
<div className={className} key={userId}>
<video onClick={markActive} onLoadedMetadata={play} src={url} />
</div>
);
});
return (<div className="app">
<div className="videos">
{videos}
</div>
</div>);
}
module.exports = app;

View File

@ -0,0 +1,5 @@
const Dispatcher = require('flux').Dispatcher;
const dispatcher = new Dispatcher();
module.exports = dispatcher;

39
src/js/index.js Normal file
View File

@ -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(<App />, 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);
});
});

8
src/js/peer/Peer.js Normal file
View File

@ -0,0 +1,8 @@
'use strict';
const Peer = require('simple-peer');
function init(opts) {
return Peer(opts);
}
module.exports = { init };

View File

@ -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'
}]]);
});
});
});
});

91
src/js/peer/handshake.js Normal file
View File

@ -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 };

5
src/js/socket.js Normal file
View File

@ -0,0 +1,5 @@
let SocketIOClient = require('socket.io-client');
let socket = new SocketIOClient();
module.exports = socket;

View File

@ -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);
});
});
});

View File

@ -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);
});
});
});

View File

@ -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
};

View File

@ -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
};

70
src/less/fonts.less Normal file
View File

@ -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'; } /* '' */

BIN
src/less/fonts/icons.eot Normal file

Binary file not shown.

27
src/less/fonts/icons.svg Normal file
View File

@ -0,0 +1,27 @@
<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg">
<metadata>Copyright (C) 2015 by original authors @ fontello.com</metadata>
<defs>
<font id="icons" horiz-adv-x="1000" >
<font-face font-family="icons" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
<missing-glyph horiz-adv-x="1000" />
<glyph glyph-name="down-open-big" unicode="&#xe800;" d="m63 570l370-356 372 356q22 26 48 0 26-22 0-48l-396-392q-22-22-48 0l-396 392q-26 26 0 48 24 24 50 0z" horiz-adv-x="866" />
<glyph glyph-name="down-open" unicode="&#xe801;" d="m564 422l-234-224q-18-18-40-18t-40 18l-234 224q-16 16-16 41t16 41q38 38 78 0l196-188 196 188q40 38 78 0 16-16 16-41t-16-41z" horiz-adv-x="580" />
<glyph glyph-name="mouse" unicode="&#xe802;" d="m551 130q28-80-17-157t-139-111q-94-28-175 9t-103 117l-106 384q-20 68 6 134t84 106l-96 186q-14 34 14 48 30 18 48-14l98-192q80 22 154-16t102-116z m-324 274q28 10 40 36t4 54q-10 28-35 41t-53 5q-28-10-40-36t-4-54q10-28 35-41t53-5z" horiz-adv-x="561" />
<glyph glyph-name="keyboard" unicode="&#xe803;" d="m930 650q28 0 49-21t21-49l0-460q0-30-21-50t-49-20l-860 0q-28 0-49 20t-21 50l0 460q0 28 21 49t49 21l860 0z m-380-100l0-100 100 0 0 100-100 0z m150-150l-100 0 0-100 100 0 0 100z m-300 150l0-100 100 0 0 100-100 0z m150-150l-100 0 0-100 100 0 0 100z m-300 150l0-100 100 0 0 100-100 0z m150-150l-100 0 0-100 100 0 0 100z m-300 150l0-100 100 0 0 100-100 0z m150-150l-100 0 0-100 100 0 0 100z m-50-250l0 100-100 0 0-100 100 0z m550 0l0 100-500 0 0-100 500 0z m150 0l0 100-100 0 0-100 100 0z m-150 150l100 0 0 100-100 0 0-100z m150 150l0 100-200 0 0-100 200 0z" horiz-adv-x="1000" />
<glyph glyph-name="left-open" unicode="&#xe804;" d="m242 626q14 16 39 16t41-16q38-36 0-80l-186-196 186-194q38-44 0-80-16-16-40-16t-40 16l-226 236q-16 16-16 38 0 24 16 40 206 214 226 236z" horiz-adv-x="341" />
<glyph glyph-name="right-open" unicode="&#xe805;" d="m98 626l226-236q16-16 16-40 0-22-16-38l-226-236q-16-16-40-16t-40 16q-36 36 0 80l186 194-186 196q-36 44 0 80 16 16 41 16t39-16z" horiz-adv-x="340" />
<glyph glyph-name="up-open" unicode="&#xe806;" d="m564 280q16-16 16-41t-16-41q-38-38-78 0l-196 188-196-188q-40-38-78 0-16 16-16 41t16 41l234 224q16 16 40 16t40-16z" horiz-adv-x="580" />
<glyph glyph-name="arrows" unicode="&#xe807;" d="m784 111l127 128 0-336-335 0 128 130-128 127 79 79z m-431 686l-129-127 128-127-80-80-126 128-128-129 0 335 335 0z m0-637l-129-127 129-130-335 0 0 336 128-128 128 128z m558 637l0-335-127 129-128-128-79 80 127 127-128 127 335 0z" horiz-adv-x="928" />
<glyph glyph-name="up-hand" unicode="&#xe808;" d="m714-43q0 15-10 25t-25 11-26-11-10-25 10-25 26-11 25 11 10 25z m72 426q0 106-93 106-15 0-32-3-9 17-29 27t-41 9-39-10q-27 30-66 30-14 0-31-6t-26-14v185q0 29-22 50t-50 22q-28 0-50-22t-21-50v-321q-11 0-27 8t-31 19-38 18-47 8q-37 0-55-25t-17-64q0-13 78-50 25-14 36-21 36-22 81-62 45-40 59-57 32-38 32-78v-18h357v18q0 40 18 93t36 108 18 100z m71 3q0-74-38-179-33-92-33-125v-161q0-29-21-50t-51-21h-357q-29 0-50 21t-21 50v161q0 6-3 12t-8 13-10 13-12 13-12 12-12 10-10 8q-41 36-72 56-11 7-34 18t-40 21-36 23-27 30-10 39q0 70 37 115t106 46q38 0 71-13v209q0 58 43 101t100 42q58 0 101-42t42-101v-94q35-2 66-21 12 2 24 2 57 0 100-34 77 1 122-47t45-127z" horiz-adv-x="857.1" />
<glyph glyph-name="check" unicode="&#xe80b;" d="m249 0q-34 0-56 28l-180 236q-16 24-12 52t26 46 51 14 47-28l118-154 296 474q16 24 43 30t53-8q24-16 30-43t-8-53l-350-560q-20-32-56-32z" horiz-adv-x="667" />
<glyph glyph-name="cancel" unicode="&#xe80c;" d="m452 194q18-18 18-43t-18-43q-18-16-43-16t-43 16l-132 152-132-152q-18-16-43-16t-43 16q-16 18-16 43t16 43l138 156-138 158q-16 18-16 43t16 43q18 16 43 16t43-16l132-152 132 152q18 16 43 16t43-16q18-18 18-43t-18-43l-138-158z" horiz-adv-x="470" />
<glyph glyph-name="level-up" unicode="&#xe80d;" d="m200 350l0-90-200 160 200 170 0-100 550 0q40 0 70-29t30-71l0-280-140 0 0 240-510 0z" horiz-adv-x="850" />
<glyph glyph-name="login" unicode="&#xe80e;" d="m800 800q42 0 71-29t29-71l0-700q0-40-29-70t-71-30l-450 0q-40 0-69 30t-29 70l0 100 98 0 0-100 450 0 0 700-450 0 0-150-98 0 0 150q0 42 29 71t69 29l450 0z m-350-670l0 120-450 0 0 150 450 0 0 120 200-194z" horiz-adv-x="900" />
<glyph glyph-name="left-open-big" unicode="&#xe81d;" d="m452-20q26-26 0-48-26-26-48 0l-392 394q-24 24 0 50l392 394q22 26 48 0 26-22 0-48l-358-372z" horiz-adv-x="465" />
<glyph glyph-name="right-open-big" unicode="&#xe81e;" d="m13-20l358 370-358 372q-26 26 0 48 26 26 48 0l392-394q24-26 0-50l-392-394q-22-26-48 0-26 22 0 48z" horiz-adv-x="465" />
<glyph glyph-name="up-open-big" unicode="&#xe81f;" d="m804 130l-372 358-370-358q-26-22-50 0-24 24 0 50l396 390q26 26 48 0l396-390q24-26 0-50-26-22-48 0z" horiz-adv-x="864" />
</font>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.7 KiB

BIN
src/less/fonts/icons.ttf Normal file

Binary file not shown.

BIN
src/less/fonts/icons.woff Normal file

Binary file not shown.

72
src/less/main.less Normal file
View File

@ -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;
}
}
}
}

BIN
src/res/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@ -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'
}]
}
]]);
});
});
});
});

36
src/server/socket.js Normal file
View File

@ -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 };
});
}
};

16
src/views/index.jade Normal file
View File

@ -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')