diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..cdfdee7 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,13 @@ +language: node_js +node_js: + - node +cache: + directories: + - node_modules +before_install: + - sudo apt-get -qq update + - sudo apt-get install -y build-essential libavahi-compat-libdnssd-dev +before_script: + - npm install -g gulp-cli +script: gulp +script: npm test \ No newline at end of file diff --git a/README.md b/README.md index a77e4b2..191665c 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,7 @@ MultiCast v1.0 ========= [![npm version](https://badge.fury.io/js/multicast.svg)](https://badge.fury.io/js/multicast) +[![Build Status](https://travis-ci.org/superhawk610/multicast.svg?branch=wip)](https://travis-ci.org/superhawk610/multicast) :green_heart: A persistent solution to presenting content across multiple Chromecast devices, inspired by [Greenscreen](http://greenscreen.io/). diff --git a/app/config.js b/app/config.js index a685d1e..ba66013 100644 --- a/app/config.js +++ b/app/config.js @@ -64,7 +64,8 @@ var startConfig = () => { console.log('') console.log('Configuration updated! To get started, just run\n' + '\n' + - ' multicast start') + ' multicast start' + '\n') }) rl.close() } else { diff --git a/app/lib/channels.js b/app/lib/channels.js new file mode 100644 index 0000000..b9e83cf --- /dev/null +++ b/app/lib/channels.js @@ -0,0 +1,44 @@ +'use strict' + +const Channel = require('../models/Channel') + +const devices = require('./devices') + +let channels = [] + +const func = { + init: () => func.refresh(), + refresh: () => { + Channel.find() + .sort('name') + .exec((err, _channels) => { + if (err) console.log(err) + channels = _channels + }) + }, + list: () => channels, + withId: id => channels.find(c => c._id == id), + create: (opts, callback) => { + var c = new Channel(opts) + c.save((err, channel) => { + if (err) console.log(err) + channels.push(c) + callback(channel._id) + }) + }, + update: (id, opts, callback) => { + Channel.update({ _id: id }, opts, err => { + Object.assign(func.withId(id), opts) + opts._id = id + devices.updateChannel(opts, () => callback(id)) + }) + }, + remove: (id, callback) => { + Channel.remove({ _id: id }, () => { + channels.splice(channels.findIndex(c => c._id == id), 1) // remove from local listing + devices.removeChannel(id, () => callback()) + }) + } +} + +module.exports = func \ No newline at end of file diff --git a/app/lib/config.js b/app/lib/config.js index 436b51d..d525ef4 100644 --- a/app/lib/config.js +++ b/app/lib/config.js @@ -1,3 +1,5 @@ +'use strict' + const fs = require('fs') const path = require('path') @@ -11,20 +13,25 @@ const configVariables = [ 'mongoPort' ] -try { +const port = 3944 +try { // Attempts to pull entire config from environment variables. If any key is // not found, the configuration will instead be drawn from the .config file // which should be placed in the project root. var envUsed = true configVariables.forEach(envVar => { - if (process.env[envVar] != undefined) module.exports[envVar] = process.env[envVar] + if (process.env[envVar] != undefined) + module.exports[envVar] = process.env[envVar] else envUsed = false // If any are empty, the .config file will be used }) // Skips if the full config is already in the environment variables - if (!envUsed) module.exports = JSON.parse(fs.readFileSync(configPath)) - + if (!envUsed) { + let opts = JSON.parse(fs.readFileSync(configPath)) + opts.port = port + module.exports = opts + } } catch (e) { console.log(`No config file found in ${configPath} Please run diff --git a/app/lib/connection.js b/app/lib/connection.js new file mode 100644 index 0000000..d19922f --- /dev/null +++ b/app/lib/connection.js @@ -0,0 +1,114 @@ +'use strict' + +const Client = require('castv2').Client +const config = require('./config') + +let func = { + establish: (d, disconnectCallback) => { + let host = d.address + d.connectionFailCount = 0 + if (d.status == 'offline' || d.status == 'waiting') { + const client = new Client() + client.connect(host, () => { + // reset number of failed connection attempts + d.connectionFailCount = 0 + + // create various namespace handlers + d.connection = client.createChannel( + 'sender-0', + 'receiver-0', + 'urn:x-cast:com.google.cast.tp.connection', + 'JSON' + ) + d.heartbeat = client.createChannel( + 'sender-0', + 'receiver-0', + 'urn:x-cast:com.google.cast.tp.heartbeat', + 'JSON' + ) + d.receiver = client.createChannel( + 'sender-0', + 'receiver-0', + 'urn:x-cast:com.google.cast.receiver', + 'JSON' + ) + + // establish virtual connection to the receiver + d.connection.send({ type: 'CONNECT' }) + + // start heartbeating + d.missedHeartbeats = 0 + d.pulse = setInterval(() => { + d.missedHeartbeats++ + if (d.missedHeartbeats > 6) { + // receiver has been offline for more than 30 seconds + d.status = 'offline' // mark receiver as offline + clearInterval(d.pulse) // stop checking for pulse :( + func.addError( + d, + 'Receiver missed too many heartbeats, it is likely offline.' + ) + } else d.heartbeat.send({ type: 'PING' }) + }, 5 * 1000) + d.heartbeat.on('message', (data, broadcast) => { + if (data.type == 'PONG') { + d.missedHeartbeats = 0 + d.status = 'online' + func.clearErrors(d) + } + }) + + // launch hub app + d.receiver.send({ type: 'LAUNCH', appId: config.appId, requestId: 1 }) + + // monitor receiver status updates to insure hub is open + d.receiver.on('message', (data, broadcast) => { + if (data.type != 'RECEIVER_STATUS') + func.addError(d, `Message from receiver: ${data.type}`) + if ((data.type = 'RECEIVER_STATUS')) { + // data.status contains relevant information about current app, volume, etc + if (data.status && data.status.applications) { + var apps = data.status.applications + + /* Backdrop means that our hub applications has stopped running, so we need to restart it */ + if (apps.find(a => a.displayName == 'Backdrop')) { + func.addError(d, 'Receiver closed hub, relaunching...') + d.receiver.send({ + type: 'LAUNCH', + appId: config.appId, + requestId: 1 + }) + } + } + } + }) + }) + client.on('error', () => { + d.connectionFailCount++ + if (d.connectionFailCount > 6) { + // receiver hasn't responded after 60 seconds + clearTimeout(d.connectionFail) + func.addError( + d, + 'Receiver is unresponsive, attempting to reconnect...' + ) + } else d.connectionFail = setTimeout(disconnectCallback, 10 * 1000) + }) + } + }, + hasErrors: () => Object.keys(errors).length > 0, + getErrors: () => errors, + addError: (device, message) => { + if (!errors[device.deviceId]) + errors[device.deviceId] = { + name: device.location, + messages: [] + } + if (errors[device.deviceId].messages.indexOf(message) == -1) + errors[device.deviceId].messages.push(message) + }, + clearErrors: device => delete errors[device.deviceId] + }, + errors = {} + +module.exports = func diff --git a/app/lib/dbConnect.js b/app/lib/dbConnect.js index 91832fa..5cda28f 100644 --- a/app/lib/dbConnect.js +++ b/app/lib/dbConnect.js @@ -1,9 +1,19 @@ +'use strict' + const mongoose = require('mongoose') module.exports = config => { - const userPassword = config.mongoUser ? `${config.mongoUser}:${config.mongoPass}@` : '' - mongoose.connect( - `mongodb://${userPassword}${config.mongoHost}:${config.mongoPort}/multicast?authSource=${config.mongoAuthSource}`, { - useMongoClient: true - }).on('error', () => console.log('Could not connect to Mongo.')) -} \ No newline at end of file + const userPassword = config.mongoUser + ? `${config.mongoUser}:${config.mongoPass}@` + : '' + mongoose + .connect( + `mongodb://${userPassword}${config.mongoHost}:${ + config.mongoPort + }/multicast?authSource=${config.mongoAuthSource}`, + { + useMongoClient: true + } + ) + .on('error', () => console.log('Could not connect to Mongo.')) +} diff --git a/app/lib/devices.js b/app/lib/devices.js new file mode 100644 index 0000000..10a4127 --- /dev/null +++ b/app/lib/devices.js @@ -0,0 +1,148 @@ +'use strict' + +const mdns = require('mdns') + +const Chromecast = require('../models/Chromecast') +const Channel = require('../models/Channel') + +const sockets = require('./sockets') +const connection = require('./connection') + +let devices = [] + +const func = { + init: () => { + Chromecast.find() + .populate('channel') + .exec((err, chromecasts) => { + devices = chromecasts + devices.forEach(d => (d.status = 'offline')) + findDevices() + /* start interval to continue polling for device status */ + setInterval(() => { + func.refresh() + }, 30 * 1000) + }) + }, + refresh: () => findDevices(), + list: () => devices, + isRegistered: id => devices.findIndex(d => d.deviceId == id) > -1, + isOffline: id => func.withId(id).status == 'offline', + isOnline: id => func.withId(id).status == 'online', + setStatus: (id, status) => (func.withId(id).status = status), + withId: id => devices.find(d => d.deviceId == id), + withHost: host => devices.find(d => d.address == host), + update: (id, opts) => { + let i = devices.findIndex(d => d.deviceId == id) + Object.assign(devices[i], opts) + }, + register: (id, opts) => { + let d = func.withId(id) + + delete d.unregistered // mark local info as registered + func.update(id, opts) // update local info with any new details + + /* Launch hub on newly registered device */ + let c = sockets.withHost(d.address) + if (c) + c.emit('register', d.deviceId) // emit 'register' to have setup page redirected + else launchHub(d.address) // establish connection if client hasn't already connected + }, + reconnect: host => launchHub(host), + updateChannel: (channel, callback) => { + for (let i in devices) { + let d = devices[i] + if (d.channel && d.channel._id == channel._id) { + Object.assign(d.channel, channel) + let c = sockets.withHost(d.address) + if (c) c.emit('refresh') + } + } + callback() + }, + removeChannel: (id, callback) => { + for (let i in devices) { + let d = devices[i] + if (d.channel && d.channel._id == id) delete d.channel + let c = sockets.withHost(d.address) + if (c) c.emit('change_channel', null) + } + + Chromecast.update({ channel: id }, { $unset: { channel: 1 } }, err => { + if (err) console.log(err) + callback() + }) + }, + refreshChannelPath: _path => { + for (let i in devices) { + let d = devices[i] + if (d.channel && d.channel.URLs[0].match(new RegExp(`/${_path}`))) { + let c = sockets.withHost(d.address) + if (c) c.emit('refresh') + } + } + }, + refreshAll: callback => { + for (let i in devices) { + let d = devices[i], + c = sockets.withHost(d.address) + if (c) c.emit('refresh') + } + callback() + } +} + +const findDevices = () => { + /* Look for mDNS Cast devices on local network */ + let browser = mdns.createBrowser(mdns.tcp('googlecast')) + + /* Only scan IPv4 addresses */ + mdns.Browser.defaultResolverSequence[1] = + 'DNSServiceGetAddrInfo' in mdns.dns_sd + ? mdns.rst.DNSServiceGetAddrInfo() + : mdns.rst.getaddrinfo({ families: [4] }) + + browser.on('serviceUp', service => { + // service.name: Chromecast-hexadecimalid + let _id = service.name.split('-'), + id = _id.pop() + + // fix for groups with IDs that resemble Google-Cast-Group-{deviceId}-1 + while (_id.length > 0 && id.length < 3) id = _id.pop() + + /* If device is registered, append local statistics */ + if (func.isRegistered(id)) { + func.update(id, { + name: service.txtRecord.fn, + address: service.addresses[0], + port: service.port + }) + if (func.isOffline(id)) { + func.setStatus(id, 'waiting') + launchHub(service.addresses[0]) + } + + /* Otherwise, add info for unregistered device */ + } else { + devices.push({ + unregistered: true, + deviceId: id, + name: service.txtRecord.fn, + address: service.addresses[0], + port: service.port + }) + } + }) + + /* Begin searching */ + browser.start() + + /* Stop searching after 15 seconds */ + setTimeout(() => browser.stop(), 15 * 1000) + }, + /* Establish connection with Chromecast */ + launchHub = host => { + if (host) connection.establish(func.withHost(host), () => launchHub(host)) + } + +module.exports = func diff --git a/app/lib/sockets.js b/app/lib/sockets.js new file mode 100644 index 0000000..7e99973 --- /dev/null +++ b/app/lib/sockets.js @@ -0,0 +1,26 @@ +'use strict' + +const stripIPv6 = require('./stripIPv6') +let io + +let clients = [] + +const func = { + init: server => { + io = require('socket.io')(server) + io.on('connection', client => { + clients.push(client) + client.on('disconnect', () => { + console.log( + 'disconnected client with host:', + stripIPv6(client.handshake.address) + ) + clients.splice(clients.indexOf(client), 1) + }) + }) + }, + list: () => clients, + withHost: host => clients.find(c => stripIPv6(c.handshake.address) == host) +} + +module.exports = func diff --git a/app/lib/stripIPv6.js b/app/lib/stripIPv6.js new file mode 100644 index 0000000..715caaa --- /dev/null +++ b/app/lib/stripIPv6.js @@ -0,0 +1 @@ +module.exports = ip => ip.replace(/^.*:/, '') diff --git a/app/lib/takeover.js b/app/lib/takeover.js new file mode 100644 index 0000000..4c47014 --- /dev/null +++ b/app/lib/takeover.js @@ -0,0 +1,27 @@ +'use strict' + +const Channel = require('../models/Channel') +const devices = require('../lib/devices') +const sockets = require('../lib/sockets') + +var takeover = null + +const func = { + isActive: () => takeover != null, + channel: () => takeover, + activate: (channelId, callback) => { + Channel.findOne({ _id: channelId }, (err, channel) => { + if (err) console.log(err) + + takeover = channel + devices.refreshAll(() => callback()) + }) + }, + deactivate: callback => { + takeover = null + sockets.list().forEach(c => c.emit('refresh')) + callback() + } +} + +module.exports = func \ No newline at end of file diff --git a/app/lib/ux.js b/app/lib/ux.js new file mode 100644 index 0000000..35a89b0 --- /dev/null +++ b/app/lib/ux.js @@ -0,0 +1,19 @@ +'use strict' + +const errors = [ + 'Houston, we have a problem.', + 'Yikes!', + "Something doesn't look right...", + "Something's fishy." + ], + atRandom = arr => { + let min = 0, + max = arr.length - 1 + return arr[Math.floor(Math.random() * (max - min + 1)) + min] + } + +let func = { + error: () => atRandom(errors) +} + +module.exports = func diff --git a/app/main.js b/app/main.js index 24f5dce..f4df050 100644 --- a/app/main.js +++ b/app/main.js @@ -4,9 +4,6 @@ const express = require('express') const app = express() const server = require('http').createServer(app) -const io = require('socket.io')(server) -const Client = require('castv2').Client -const mdns = require('mdns') const mongoose = require('mongoose') mongoose.Promise = require('bluebird') const bodyParser = require('body-parser') @@ -14,223 +11,58 @@ const path = require('path') const fs = require('fs') const Chromecast = require('./models/Chromecast') -const Channel = require('./models/Channel') const config = require('./lib/config') const dbConnect = require('./lib/dbConnect') +const stripIPv6 = require('./lib/stripIPv6') -const port = 3944 +const devices = require('./lib/devices') +const channels = require('./lib/channels') +const sockets = require('./lib/sockets') +const takeover = require('./lib/takeover') +const connection = require('./lib/connection') +const ux = require('./lib/ux') + +const port = config.port const serveOnly = process.argv.find(arg => arg == '--serve-only') // prettier-ignore-block -var takeover = null - +/* Establish database connection */ dbConnect(config) -/* Establish connection with Chromecast devices on local network */ - -var devices = [], - findDevices = () => { - /* Look for mDNS Cast devices on local network */ - var browser = mdns.createBrowser(mdns.tcp('googlecast')) - - /* Only scan IPv4 addresses */ - mdns.Browser.defaultResolverSequence[1] = - 'DNSServiceGetAddrInfo' in mdns.dns_sd - ? mdns.rst.DNSServiceGetAddrInfo() - : mdns.rst.getaddrinfo({ families: [4] }) - - browser.on('serviceUp', service => { - // service.name: Chromecast-hexadecimalid - var id = service.name.split('-').pop(), - i = devices.findIndex(d => d.deviceId == id) - - /* If device is registered, append local statistics */ - if (i > -1) { - devices[i].name = service.txtRecord.fn - devices[i].address = service.addresses[0] - devices[i].port = service.port - if (devices[i].status == 'offline') { - devices[i].status = 'waiting' - launchHub(service.addresses[0]) - } - - /* Otherwise, add info for unregistered device */ - } else { - devices.push({ - unregistered: true, - deviceId: id, - name: service.txtRecord.fn, - address: service.addresses[0], - port: service.port - }) - } - }) - - /* Begin searching */ - browser.start() - - /* Stop searching after 15 seconds */ - setTimeout(() => browser.stop(), 15 * 1000) - }, - /* Establish connection with Chromecast */ - launchHub = host => { - if (host) { - var d = devices.find(d => d.address == host) - - d.connectionFailCount = 0 - if (d.status == 'offline' || d.status == 'waiting') { - const client = new Client() - client.connect(host, () => { - // reset number of failed connection attempts - d.connectionFailCount = 0 - - // create various namespace handlers - d.connection = client.createChannel( - 'sender-0', - 'receiver-0', - 'urn:x-cast:com.google.cast.tp.connection', - 'JSON' - ) - d.heartbeat = client.createChannel( - 'sender-0', - 'receiver-0', - 'urn:x-cast:com.google.cast.tp.heartbeat', - 'JSON' - ) - d.receiver = client.createChannel( - 'sender-0', - 'receiver-0', - 'urn:x-cast:com.google.cast.receiver', - 'JSON' - ) - - // establish virtual connection to the receiver - d.connection.send({ type: 'CONNECT' }) - - // start heartbeating - d.missedHeartbeats = 0 - d.pulse = setInterval(() => { - d.missedHeartbeats++ - if (d.missedHeartbeats > 6) { - // receiver has been offline for more than 30 seconds - d.status = 'offline' // mark receiver as offline - clearInterval(d.pulse) // stop checking for pulse :( - } else d.heartbeat.send({ type: 'PING' }) - }, 5 * 1000) - d.heartbeat.on('message', (data, broadcast) => { - if (data.type == 'PONG') { - d.missedHeartbeats = 0 - d.status = 'online' - } - }) - - // launch hub app - d.receiver.send({ type: 'LAUNCH', appId: config.appId, requestId: 1 }) - - // monitor receiver status updates to insure hub is open - d.receiver.on('message', (data, broadcast) => { - if (data.type != 'RECEIVER_STATUS') console.log(data.type) - if ((data.type = 'RECEIVER_STATUS')) { - // data.status contains relevant information about current app, volume, etc - if (data.status && data.status.applications) { - var apps = data.status.applications - - /* Backdrop means that our hub applications has stopped running, so we need to restart it */ - if (apps.find(a => a.displayName == 'Backdrop')) { - console.log('relaunching hub...') - d.receiver.send({ - type: 'LAUNCH', - appId: config.appId, - requestId: 1 - }) - } - } - } - }) - }) - client.on('error', () => { - d.connectionFailCount++ - if (d.connectionFailCount > 6) { - // receiver hasn't responded after 60 seconds - clearTimeout(d.connectionFail) - } else d.connectionFail = setTimeout(() => launchHub(host), 10 * 1000) - }) - } - } - } +/* Load initial channel listing */ +channels.init() /* Establish socket.io service */ - -var clients = [] -io.on('connection', client => { - clients.push(client) - client.on('disconnect', () => { - var i = clients.indexOf(client) - clients.splice(i, 1) - }) -}) +sockets.init(server) /* Express Setup */ - -app.set('views', path.join(__dirname, 'views')) app.set('view engine', 'pug') - -app.use(express.static(path.resolve(__dirname, '..', 'public'))) +app.set('views', path.join(__dirname, 'views')) app.use(bodyParser.urlencoded({ extended: false })) +app.use(express.static(path.resolve(__dirname, '..', 'public'))) +app.locals.connection = connection +app.locals.ux = ux /* Home Page */ - app.get('/', (req, res) => { res.render('index', { render: 'home', takeover: takeover }) }) /* Nyan!! */ - app.get('/nyan', (req, res) => { res.render('nyan', {}) }) /* Basic Message Channel */ - -app.get('/message', (req, res) => { - fs.readFile(path.resolve(__dirname, '..', 'message.txt'), (err, data) => { - if (err || data == '') - res.render('message', { message: 'No message configured.' }) - else res.render('message', { message: data }) - }) -}) - -app.get('/message/edit', (req, res) => { - fs.readFile(path.resolve(__dirname, '..', 'message.txt'), (err, data) => { - if (err) res.render('message-edit', { message: '' }) - else res.render('message-edit', { message: data }) - }) -}) - -app.post('/message/edit', (req, res) => { - fs.writeFile( - path.resolve(__dirname, '..', 'message.txt'), - req.body.message, - err => console.log(err) - ) - for (var i in devices) { - var d = devices[i] - if (d.channel && d.channel.URLs[0].match(new RegExp(`${port}/message`))) { - var c = clients.find(c => stripIPv6(c.handshake.address) == d.address) - if (c) c.emit('refresh') - } - } - res.sendStatus(200) -}) +app.use('/message', require('./routes/message')) /* Landing Page */ - app.get('/landing', (req, res) => { var ip = stripIPv6(req.connection.remoteAddress), // Get IPv4 address of device - d = devices.find(d => d.address == ip) // Find local info for device + d = devices.withHost(ip) // Find local info for device if (d) - res.redirect(`/device/${d.deviceId}`) // Redirect to device display // If not recognized, display information + res.redirect(`/devices/${d.deviceId}`) // Redirect to device display // Display device info else res.render('setup-chromecast', { device: { @@ -244,352 +76,24 @@ app.get('/landing', (req, res) => { }) /* Devices */ - -app.get('/devices', (req, res) => { - res.render('index', { render: 'devices', devices: devices }) -}) - -app.get('/device/new', (req, res) => { - Channel.find() - .sort('name') - .exec((err, channels) => { - if (err) console.log(err) - res.render('index', { - render: 'device', - devices: devices.filter(d => d.unregistered), - channels: channels - }) - }) -}) - -app.post('/device/new', (req, res) => { - var c = new Chromecast(req.body) - c.save((err, device) => { - if (err) console.log(err) - - /* Mark local info as registered */ - var i = devices.findIndex(d => d.deviceId == req.body.deviceId) - delete devices[i].unregistered - - /* Update local info with any new details */ - devices[i] = Object.assign(devices[i], req.body) - - /* Launch hub on newly registered device */ - var c = clients.find( - c => stripIPv6(c.handshake.address) == devices[i].address - ) - if (c) - c.emit('register', devices[i].deviceId) // emit 'register' to have setup page redirected - else launchHub(devices[i].address) // establish connection if client hasn't already connected - - res.send(device.deviceId) - }) -}) - -app.get('/device/:device_id', (req, res) => { - Chromecast.findOne({ deviceId: req.params.device_id }) - .populate('channel') - .exec((err, device) => { - if (err) console.log(err) - if (device && device.channel) { - if (takeover) - res.render(`layouts/${takeover.layout}`, { - deviceId: req.params.device_id, - channel: takeover, - casting: true - }) - else { - /* device registered and channel set - display device page */ - res.render(`layouts/${device.channel.layout}`, { - deviceId: req.params.device_id, - channel: device.channel, - casting: true - }) - } - } else { - var localDevice = devices.find(d => d.deviceId == req.params.device_id) - if (device) { - if (takeover) - res.render(`layouts/${takeover.layout}`, { - deviceId: req.params.device_id, - channel: takeover, - casting: true - }) - else { - /* device registered but no channel set - display setup page */ - res.render('setup-chromecast', { - device: Object.assign(localDevice, device), - registered: true, - setupUrl: `${req.protocol}://${req.hostname}:${port}/` - }) - } - } else { - /* device is not registered - display setup page */ - res.render('setup-chromecast', { - device: Object.assign(localDevice, device), - registered: false, - setupUrl: `${req.protocol}://${req.hostname}:${port}/` - }) - } - } - }) -}) - -app.get('/device/:device_id/connect', (req, res) => { - var d = devices.find(d => d.deviceId == req.params.device_id) - if (d.status == 'offline' || d.status == 'waiting') launchHub(d.address) - else { - // launch hub if not already open - // hard reload page if already open - var c = clients.find(c => stripIPv6(c.handshake.address) == d.address) - if (c) c.emit('refresh') - } - res.sendStatus(200) -}) - -app.post('/device/:device_id/edit', (req, res) => { - Chromecast.update( - { deviceId: req.params.device_id }, - req.body, - (err, numAffected, response) => { - if (err) console.log(err) - var i = devices.findIndex(d => d.deviceId == req.params.device_id) - devices[i].location = req.body.location // update local info with location - if (req.body.channel) { - Channel.findOne({ _id: req.body.channel }).exec((err, channel) => { - var c = clients.find( - c => stripIPv6(c.handshake.address) == devices[i].address - ) - if (c) c.emit('change_channel', channel) - devices[i].channel = channel // update local info with new channel info for any applicable devices - thing({ obj: { obj: 'foo' } }, bar) - res.send(req.params.device_id) - }) - } else { - var c = clients.find( - c => stripIPv6(c.handshake.address) == devices[i].address - ) - if (c) c.emit('change_channel', null) - res.send(req.params.device_id) - } - } - ) -}) - -app.delete('/device/:device_id/edit', (req, res) => { - Chromecast.remove({ deviceId: req.params.device_id }, () => { - /* Mark local info for device as unregistered */ - var i = devices.findIndex(d => d.deviceId == req.params.device_id) - devices[i].unregistered = true - delete devices[i].channel - delete devices[i].location - res.sendStatus(200) - }) -}) - -app.get('/device/:device_id/edit', (req, res) => { - Chromecast.findOne({ deviceId: req.params.device_id }) - .populate('channel') - .exec((err, device) => { - if (err) console.log(err) - Channel.find() - .sort('name') - .exec((err, channels) => { - if (err) console.log(err) - if (device) { - var localDevice = devices.find( - d => d.deviceId == req.params.device_id - ) - res.render('index', { - render: 'device', - device: Object.assign(localDevice, device), - channels: channels - }) - } else res.render('index', {}) - }) - }) -}) +app.use('/devices', require('./routes/devices')) /* Channels */ +app.use('/channels', require('./routes/channels')) -app.get('/channels', (req, res) => { - Channel.find() - .sort('name') - .exec((err, channels) => { - res.render('index', { render: 'channels', channels: channels }) - }) -}) - -app.get('/channel/new', (req, res) => { - res.render('index', { - render: 'channel', - host: `${req.protocol}://${req.hostname}:${port}/` - }) -}) - -app.post('/channel/new', (req, res) => { - if (req.body.URLs) req.body.URLs = req.body.URLs.filter(u => u.trim() != '') - var c = new Channel(req.body) - c.save((err, channel) => { - if (err) console.log(err) - res.send(channel._id) - }) -}) - -app.get('/channel/:channel_id', (req, res) => { - Channel.findOne({ _id: req.params.channel_id }).exec((err, channel) => { - if (err) console.log(err) - if (channel) - res.render(`layouts/${channel.layout}`, { - channel: channel, - casting: false - }) - else res.render('layouts/empty', { casting: false }) - }) -}) - -app.post('/channel/:channel_id/edit', (req, res) => { - if (req.body.URLs) req.body.URLs = req.body.URLs.filter(u => u.trim() != '') - for (var i in devices) { - var d = devices[i] - - /* update channel info on local info for any devices displaying this channel */ - if ( - d.channel && - d.channel._id.toString() == req.params.channel_id.toString() - ) - Object.assign(devices[i].channel, req.body) - } - Channel.update( - { _id: req.params.channel_id }, - req.body, - (err, numAffected, response) => { - if (err) console.log(err) - res.send(req.params.channel_id) - } - ) -}) - -app.delete('/channel/:channel_id/edit', (req, res) => { - Channel.remove({ _id: req.params.channel_id }, () => { - for (var i in devices) { - var d = devices[i] - if ( - d.channel && - d.channel._id.toString() == req.params.channel_id.toString() - ) { - /* remove channel listing from local info of relevant devices */ - delete devices[i].channel - - /* send devices on this channel back to setup page */ - var c = clients.find( - c => stripIPv6(c.handshake.address) == devices[i].address - ) - if (c) c.emit('change_channel', null) - } - } - - /* remove channel listing from Mongo */ - Chromecast.update( - { channel: new mongoose.Types.ObjectId(req.params.channel_id) }, - { $unset: { channel: 1 } }, - (err, numAffected, result) => { - res.sendStatus(200) - } - ) - }) -}) - -app.get('/channel/:channel_id/edit', (req, res) => { - Channel.findOne({ _id: req.params.channel_id }).exec((err, channel) => { - if (err) console.log(err) - if (channel) - res.render('index', { - render: 'channel', - channel: channel, - host: `${req.protocol}://${req.hostname}:${port}/` - }) - else res.render('index', {}) - }) -}) - -/* Push Alerts & Takeovers */ - -app.get('/new/push', (req, res) => { - res.render('index', { render: 'push' }) -}) - -app.post('/new/push', (req, res) => { - clients.forEach(c => { - c.emit('push', { - message: req.body.message, - style: req.body.style, - duration: req.body.duration - }) - }) - res.sendStatus(200) -}) - -app.get('/new/takeover', (req, res) => { - Channel.find() - .sort('name') - .exec((err, channels) => { - res.render('index', { render: 'takeover', channels: channels }) - }) -}) - -app.post('/new/takeover', (req, res) => { - Channel.findOne({ - _id: new mongoose.Types.ObjectId(req.body.channel_id) - }).exec((err, channel) => { - if (err) console.log(err) - takeover = channel - clients.forEach(c => { - c.emit('change_channel', channel) - }) - res.sendStatus(200) - }) -}) +/* Push Alerts */ +app.use('/push', require('./routes/push')) -app.post('/takeover/end', (req, res) => { - takeover = null - clients.forEach(c => { - c.emit('refresh') - }) - res.sendStatus(200) -}) +/* Takeover */ +app.use('/takeover', require('./routes/takeover')) /* Server */ - server.listen(port, () => { console.log('MultiCast is live!') console.log(`listening at port ${port}...`) if (!serveOnly) { - /* load saved devices */ - Chromecast.find() - .populate('channel') - .exec((err, _devices) => { - for (var i in _devices) { - var d = _devices[i].toObject() - d.status = 'offline' - devices.push(d) - } - - /* poll for active devices */ - findDevices() - - /* start interval to continue polling for device status */ - setInterval(() => { - findDevices() - }, 30 * 1000) - }) + /* poll for active devices */ + devices.init() } }) - -/* Utility */ - -var stripIPv6 = ip => ip.replace(/^.*:/, '') diff --git a/app/routes/channels.js b/app/routes/channels.js new file mode 100644 index 0000000..4324670 --- /dev/null +++ b/app/routes/channels.js @@ -0,0 +1,55 @@ +const express = require('express') +const router = express.Router() + +const channels = require('../lib/channels') + +const Chromecast = require('../models/Chromecast') +const Channel = require('../models/Channel') + +const port = require('../lib/config').port + +router.use((req, res, next) => { + if (req.body.URLs) req.body.URLs = req.body.URLs.filter(u => u.trim() != '') + next() +}) + +router.get('/', (req, res) => { + res.render('index', { render: 'channels', channels: channels.list() }) +}) + +router + .route('/new') + .get((req, res) => { + res.render('index', { + render: 'channel', + host: `${req.protocol}://${req.hostname}:${port}/` + }) + }) + .post((req, res) => channels.create(req.body, id => res.send(id))) + +router.get('/:channel_id', (req, res) => { + let channel = channels.withId(req.params.channel_id) + if (channel) + res.render(`layouts/${channel.layout}`, { + channel: channel, + casting: false + }) + else res.render('layouts/empty', { casting: false }) +}) + +router + .route('/:channel_id/edit') + .get((req, res) => { + let channel = channels.withId(req.params.channel_id) + if (channel) + res.render('index', { + render: 'channel', + channel: channel, + host: `${req.protocol}://${req.hostname}:${port}/` + }) + else res.render('index', {}) + }) + .post((req, res) => channels.update(req.params.channel_id, req.body, id => res.send(id))) + .delete((req, res) => channels.remove(req.params.channel_id, () => res.sendStatus(200))) + +module.exports = router \ No newline at end of file diff --git a/app/routes/devices.js b/app/routes/devices.js new file mode 100644 index 0000000..76b1988 --- /dev/null +++ b/app/routes/devices.js @@ -0,0 +1,133 @@ +'use strict' + +const express = require('express') +const router = express.Router() + +const Chromecast = require('../models/Chromecast') + +const devices = require('../lib/devices') +const channels = require('../lib/channels') +const sockets = require('../lib/sockets') +const takeover = require('../lib/takeover') + +const port = require('../lib/config').port + +router.get('/', (req, res) => { + res.render('index', { render: 'devices', devices: devices.list() }) +}) + +router + .route('/new') + .get((req, res) => { + res.render('index', { + render: 'device', + devices: devices.list().filter(d => d.unregistered), + channels: channels.list() + }) + }) + .post((req, res) => { + let c = new Chromecast(req.body) + c.save((err, device) => { + if (err) console.log(err) + devices.register(device.deviceId, req.body) + res.send(device.deviceId) + }) + }) + +router.get('/:device_id', (req, res) => { + let d = devices.withId(req.params.device_id) + if (d && takeover.isActive()) { + res.render(`layouts/${takeover.channel().layout}`, { + deviceId: req.params.device_id, + channel: takeover.channel(), + casting: true + }) + } else { + if (d) { + if (d.channel) { + /* device registered and channel set + display device page */ + res.render(`layouts/${d.channel.layout}`, { + deviceId: req.params.device_id, + channel: d.channel, + casting: true + }) + } else { + /* device registered but no channel set + display setup page */ + res.render('setup-chromecast', { + device: d, + registered: true, + setupUrl: `${req.protocol}://${req.hostname}:${port}/` + }) + } + } else { + /* device is not registered + display setup page */ + res.render('setup-chromecast', { + device: d, + registered: false, + setupUrl: `${req.protocol}://${req.hostname}:${port}/` + }) + } + } +}) + +router.get('/:device_id/connect', (req, res) => { + let d = devices.withId(req.params.device_id) + if (!devices.isOnline(req.params.device_id)) devices.reconnect(d.address) + else { + // launch hub if not already open + // hard reload page if already open + var c = sockets.withHost(d.address) + if (c) c.emit('refresh') + } + res.sendStatus(200) +}) + +router + .route('/:device_id/edit') + .get((req, res) => { + let d = devices.withId(req.params.device_id) + if (d) { + res.render('index', { + render: 'device', + device: d, + channels: channels.list() + }) + } else render('index', {}) + }) + .post((req, res) => { + Chromecast.update( + { deviceId: req.params.device_id }, + req.body, + err => { + if (err) console.log(err) + + let d = devices.withId(req.params.device_id) + d.location = req.body.location // update local info with location + console.log('host', d.address) + let c = sockets.withHost(d.address), + channel = null + if (req.body.channel) { + channel = channels.withId(req.body.channel) + d.channel = channel // update local info with channel + } + console.log('socket_client', c) + if (c) c.emit('change_channel', channel) + res.send(req.params.device_id) + } + ) + }) + .delete((req, res) => { + Chromecast.remove({ deviceId: req.params.device_id }, () => { + /* Mark local info for device as unregistered */ + let d = devices.withId(req.params.device_id) + d.unregistered = true + delete d.channel + delete d.location + res.sendStatus(200) + }) + }) + +module.exports = router diff --git a/app/routes/message.js b/app/routes/message.js new file mode 100644 index 0000000..495ac74 --- /dev/null +++ b/app/routes/message.js @@ -0,0 +1,32 @@ +'use strict' + +const express = require('express') +const router = express.Router() +const path = require('path') +const fs = require('fs') +const devices = require('../lib/devices') +const messageFile = path.resolve(__dirname, '..', '..', 'message.txt') + +router.get('/', (req, res) => { + fs.readFile(messageFile, (err, data) => { + if (err || data == '') + res.render('message', { message: 'No message configured.' }) + else res.render('message', { message: data }) + }) +}) + +router + .route('/edit') + .get((req, res) => { + fs.readFile(messageFile, (err, data) => { + if (err) res.render('message-edit', { message: '' }) + else res.render('message-edit', { message: data }) + }) + }) + .post((req, res) => { + fs.writeFile(messageFile, req.body.message, err => console.log(err)) + devices.refreshChannelPath('message') + res.sendStatus(200) + }) + +module.exports = router \ No newline at end of file diff --git a/app/routes/push.js b/app/routes/push.js new file mode 100644 index 0000000..8a060fd --- /dev/null +++ b/app/routes/push.js @@ -0,0 +1,23 @@ +'use strict' + +const express = require('express') +const router = express.Router() + +const sockets = require('../lib/sockets') + +router.route('/') +.get((req, res) => { + res.render('index', { render: 'push' }) +}) +.post((req, res) => { + sockets.list().forEach(c => { + c.emit('push', { + message: req.body.message, + style: req.body.style, + duration: req.body.duration + }) + }) + res.sendStatus(200) +}) + +module.exports = router \ No newline at end of file diff --git a/app/routes/takeover.js b/app/routes/takeover.js new file mode 100644 index 0000000..5cadb72 --- /dev/null +++ b/app/routes/takeover.js @@ -0,0 +1,24 @@ +'use strict' + +const express = require('express') +const router = express.Router() +'use strict' + +const channels = require('../lib/channels') +const takeover = require('../lib/takeover') + +router + .route('/') + .get((req, res) => { + res.render('index', { render: 'takeover', channels: channels.list() }) + }) + .post((req, res) => { + console.log('activating takeover!') + takeover.activate(req.body.channel_id, () => res.sendStatus(200)) + }) + +router.post('/end', (req, res) => { + takeover.deactivate(() => res.sendStatus(200)) +}) + +module.exports = router \ No newline at end of file diff --git a/app/views/channels.pug b/app/views/channels.pug index 458ead1..139caa7 100644 --- a/app/views/channels.pug +++ b/app/views/channels.pug @@ -1,5 +1,5 @@ h1 Channels - a.btn.btn-primary.float-right(href='/channel/new') + a.btn.btn-primary.float-right(href='/channels/new') i.fa.fa-plus | Add Channel table.table @@ -12,9 +12,9 @@ table.table each c in channels tr td - a(href=`/channel/${c._id}/edit`)= c.name + a(href=`/channels/${c._id}/edit`)= c.name td.text-right - a(href=`/channel/${c._id}`) Preview + a(href=`/channels/${c._id}`) Preview else tr td.text-gray(colspan=42) No channels available. \ No newline at end of file diff --git a/app/views/devices.pug b/app/views/devices.pug index f62f3b6..4fab704 100644 --- a/app/views/devices.pug +++ b/app/views/devices.pug @@ -1,5 +1,5 @@ h1 Chromecasts - a.btn.btn-primary.float-right(href='/device/new') + a.btn.btn-primary.float-right(href='/devices/new') i.fa.fa-plus | Register Chromecast table.table @@ -14,21 +14,21 @@ table.table tr td if d.unregistered - a.badge(href='/device/new', class=d.status)= d.name + a.badge(href='/devices/new', class=d.status)= d.name div Unregistered else - a.badge(href=`/device/${d.deviceId}/edit`, class=d.status)= d.name + a.badge(href=`/devices/${d.deviceId}/edit`, class=d.status)= d.name div= d.location td if d.unregistered - | unregistered + span(style='color: #ccc') unregistered else if d.channel - a(href=`/channel/${d.channel._id}/edit`)= d.channel.name + a(href=`/channels/${d.channel._id}/edit`)= d.channel.name else .text-gray no channel td.text-right - a(href=`/device/${d.deviceId}`) Preview + a(href=`/devices/${d.deviceId}`) Preview else tr td.text-gray(colspan=42) No devices available. \ No newline at end of file diff --git a/app/views/home.pug b/app/views/home.pug index ef299da..cf5baf2 100644 --- a/app/views/home.pug +++ b/app/views/home.pug @@ -1,8 +1,8 @@ a.btn.btn-lg.btn-primary.btn-block(href='/devices') Chromecasts a.btn.btn-lg.btn-primary.btn-block(href='/channels') Channels -a.btn.btn-lg.btn-primary.btn-block(href='/new/push') Push Alert -unless takeover - a.btn.btn-lg.btn-primary.btn-block(href='/new/takeover') Channel Takeover +a.btn.btn-lg.btn-primary.btn-block(href='/push') Push Alert +unless takeover.isActive() + a.btn.btn-lg.btn-primary.btn-block(href='/takeover') Channel Takeover else a.btn.btn-lg.btn-error.btn-block#stop-takeover Stop Channel Takeover hr diff --git a/app/views/include/error.pug b/app/views/include/error.pug new file mode 100644 index 0000000..7b65e96 --- /dev/null +++ b/app/views/include/error.pug @@ -0,0 +1 @@ +#error(style='display: none') \ No newline at end of file diff --git a/app/views/include/nav.pug b/app/views/include/nav.pug index cdcaf64..1f02d2e 100644 --- a/app/views/include/nav.pug +++ b/app/views/include/nav.pug @@ -7,4 +7,17 @@ header.navbar.bg-success(style='padding: 0.3em') a.btn.btn-link(style='color: #fff', href='/devices') Devices a.btn.btn-link(style='color: #fff', href='/channels') Channels +if connection.hasErrors() + .toast.toast-error.connection-errors + strong= ux.error() + ul + each e in connection.getErrors() + each m in e.messages + li + strong= e.name + | + i.fa.fa-angle-right + | + = m + include toast.pug \ No newline at end of file diff --git a/app/views/layouts/fullscreen.pug b/app/views/layouts/fullscreen.pug index 6c0b87c..f6c04ba 100644 --- a/app/views/layouts/fullscreen.pug +++ b/app/views/layouts/fullscreen.pug @@ -6,6 +6,7 @@ html(lang='en') link(rel='stylesheet', href='/css/channel.css') body.fullscreen + include ../include/error.pug include ../include/toast.pug if channel.URLs != null && channel.URLs.length iframe(src=channel.URLs[0], frameborder=0, framespacing=0) diff --git a/app/views/layouts/right-panel.pug b/app/views/layouts/right-panel.pug index af4f658..603aeb0 100644 --- a/app/views/layouts/right-panel.pug +++ b/app/views/layouts/right-panel.pug @@ -6,6 +6,7 @@ html(lang='en') link(rel='stylesheet', href='/css/channel.css') body.right-panel + include ../include/error.pug include ../include/toast.pug if channel.URLS != null && channel.URLs.length iframe(src=channel.URLs[0], frameborder=0, framespacing=0) diff --git a/app/views/layouts/template.pug b/app/views/layouts/template.pug index 5a44efd..b0a6810 100644 --- a/app/views/layouts/template.pug +++ b/app/views/layouts/template.pug @@ -6,6 +6,7 @@ html(lang='en') link(rel='stylesheet', href='/css/channel.css') body.layout-name + include ../include/error.pug include ../include/toast.pug if channel.URLS != null && channel.URLs.length each u in channel.URLS diff --git a/build/css/channel.css b/build/css/channel.css index 46d598f..0998c06 100644 --- a/build/css/channel.css +++ b/build/css/channel.css @@ -1,4 +1,5 @@ -html, body { +html, +body { margin: 0; padding: 0; width: 100%; @@ -6,26 +7,48 @@ html, body { } body.fullscreen iframe { - width: 100vw; height: 100vh; + width: 100vw; + height: 100vh; } body.right-panel iframe:first-of-type { position: absolute; - top: 0; left: 0; - width: 80vw; height: 100vh; + top: 0; + left: 0; + width: 80vw; + height: 100vh; } body.right-panel iframe:nth-of-type(2) { position: absolute; - top: 0; left: 80vw; - width: 20vw; height: 100vh; + top: 0; + left: 80vw; + width: 20vw; + height: 100vh; +} + +#error { + position: absolute; + top: 0; + left: 0; + width: 100vw; + background: #000; + color: #fff; + padding: 20px; +} + +#error span { + font-weight: 700; + color: #f00; } #toast.full-width { width: calc(100% - 20px); - left: 10px; bottom: 15px; right: 10px; + left: 10px; + bottom: 15px; + right: 10px; } #toast.full-width .title { font-size: 7.5vh; -} \ No newline at end of file +} diff --git a/build/css/main.css b/build/css/main.css index a4fb127..8db2d2f 100644 --- a/build/css/main.css +++ b/build/css/main.css @@ -1,4 +1,5 @@ -html, body { +html, +body { background: #f8f9fa; } @@ -6,7 +7,8 @@ html, body { width: 400px; max-width: 100%; position: fixed; - bottom: 20px; right: 20px; + bottom: 20px; + right: 20px; } .toast .title, @@ -14,14 +16,20 @@ html, body { margin: 0; } -.toast .title + .text { - margin-top: .25em; +.toast .title + .text { + margin-top: 0.25em; } -.toast :empty { +.toast:empty { display: none; } +.connection-errors > ul { + list-style: none; + margin: 0; + padding: 0; +} + #wrapper { background: #fff; padding: 3em; @@ -44,7 +52,7 @@ html, body { .btn-error:hover, .btn-error:focus, .btn-error:active { - border-color: #e85600; + border-color: #e85600; background: #df5200; color: #fff; } @@ -74,4 +82,4 @@ footer { width: 100%; min-height: 100px; padding: 3em; -} \ No newline at end of file +} diff --git a/build/js/app.js b/build/js/app.js index eda9053..db32180 100644 --- a/build/js/app.js +++ b/build/js/app.js @@ -3,17 +3,53 @@ function notify(opts) { if (notifyIsActive) return notifyIsActive = true var title = opts.title || '', - msg = opts.message || '', - style = opts.style != '' ? `toast-${opts.style}` : '', - full = opts.fullWidth || false, - dur = opts.duration || 3000 - $('#toast').find('.title').text(title).end() - .find('.text').text(msg).end() - .addClass(`${style}${full ? ' full-width' : ''}`).fadeIn() + msg = opts.message || '', + style = opts.style != '' ? `toast-${opts.style}` : '', + full = opts.fullWidth || false, + dur = opts.duration || 3000 + $('#toast') + .find('.title') + .text(title) + .end() + .find('.text') + .text(msg) + .end() + .addClass(`${style}${full ? ' full-width' : ''}`) + .fadeIn() setTimeout(function() { $('#toast').fadeOut(500, function() { $('#toast').removeClass(`${style} full-width`) notifyIsActive = false }) }, dur) -} \ No newline at end of file +} + +function displayError(message) { + $('#error') + .append( + `