├── .gitignore ├── LICENSE ├── README.md ├── index.js └── package.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | package-lock.json 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Mathias Buus 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # chromecasts 2 | 3 | Query your local network for Chromecasts and have them play media 4 | 5 | ```bash 6 | npm install chromecasts --save 7 | ``` 8 | 9 | ## Usage 10 | 11 | ```js 12 | const chromecasts = require('chromecasts')() 13 | 14 | chromecasts.on('update', players => { 15 | console.log('all players: ', players) 16 | player.play('http://example.com/my-video.mp4', {title: 'my video', type: 'video/mp4'}) 17 | }) 18 | ``` 19 | 20 | ## API 21 | 22 | #### `var list = chromecasts()` 23 | 24 | Creates a chromecast list. 25 | When creating a new list it will call `list.update()` once. 26 | It is up to you to call afterwards incase you want to update the list. 27 | 28 | #### `list.update()` 29 | 30 | Updates the player list by querying the local network for chromecast instances. 31 | 32 | #### `list.on('update', player)` 33 | 34 | Emitted when a new player is found on the local network 35 | 36 | #### `player.play(url, [opts], cb)` 37 | 38 | Make the player play a url. Options include: 39 | 40 | ```js 41 | { 42 | title: 'My movie', 43 | type: 'video/mp4', 44 | seek: seconds, // start by seeking to this offset 45 | subtitles: ['http://example.com/sub.vtt'], // subtitle track 1, 46 | autoSubtitles: true // enable first track if you provide subs 47 | } 48 | ``` 49 | 50 | #### `player.subtitles(track, [cb])` 51 | 52 | Enable subtitle track. Use `player.subtitles(false)` to disable subtitles 53 | 54 | #### `player.pause([cb])` 55 | 56 | Make the player pause playback 57 | 58 | #### `player.resume([cb])` 59 | 60 | Resume playback 61 | 62 | #### `player.stop([cb])` 63 | 64 | Stop the playback 65 | 66 | #### `player.seek(seconds, [cb])` 67 | 68 | Seek the video 69 | 70 | #### `player.status(cb)` 71 | 72 | Get a status object of the current played video. 73 | 74 | #### `player.on('status', status)` 75 | 76 | Emitted when a status object is received. 77 | 78 | ## License 79 | 80 | MIT 81 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var castv2 = require('castv2-client') 2 | var debug = require('debug')('chromecasts') 3 | var events = require('events') 4 | var get = require('simple-get') 5 | var mdns = require('multicast-dns') 6 | var mime = require('mime') 7 | var parseString = require('xml2js').parseString 8 | var txt = require('dns-txt')() 9 | 10 | var SSDP 11 | try { 12 | SSDP = require('node-ssdp').Client 13 | } catch (err) { 14 | SSDP = null 15 | } 16 | 17 | var thunky = require('thunky') 18 | var url = require('url') 19 | 20 | var noop = function () {} 21 | var toMap = function (url) { 22 | return typeof url === 'string' ? {url: url} : url 23 | } 24 | var toSubtitles = function (url, i) { 25 | if (typeof url !== 'string') return url 26 | return { 27 | trackId: i + 1, 28 | type: 'TEXT', 29 | trackContentId: url, 30 | trackContentType: 'text/vtt', 31 | name: 'English', 32 | language: 'en-US', 33 | subtype: 'SUBTITLES' 34 | } 35 | } 36 | 37 | module.exports = function () { 38 | var dns = mdns() 39 | var that = new events.EventEmitter() 40 | var casts = {} 41 | var ssdp = SSDP ? new SSDP({logLevel: process.env.DEBUG ? 'trace' : false}) : null 42 | 43 | that.players = [] 44 | 45 | var emit = function (cst) { 46 | if (!cst || !cst.host || cst.emitted) return 47 | cst.emitted = true 48 | 49 | var player = new events.EventEmitter() 50 | 51 | var connect = thunky(function reconnect (cb) { 52 | var client = new castv2.Client() 53 | 54 | client.on('error', function (err) { 55 | player.emit('error', err) 56 | }) 57 | 58 | client.on('close', function () { 59 | connect = thunky(reconnect) 60 | }) 61 | 62 | client.client.on('close', function () { 63 | connect = thunky(reconnect) 64 | }) 65 | 66 | client.connect(player.host, function (err) { 67 | if (err) return cb(err) 68 | player.emit('connect') 69 | 70 | client.getSessions(function (err, sess) { 71 | if (err) return cb(err) 72 | 73 | var session = sess[0] 74 | if (session && session.appId === castv2.DefaultMediaReceiver.APP_ID) { 75 | client.join(session, castv2.DefaultMediaReceiver, ready) 76 | } else { 77 | client.launch(castv2.DefaultMediaReceiver, ready) 78 | } 79 | }) 80 | 81 | function ready (err, p) { 82 | if (err) return cb(err) 83 | 84 | player.emit('ready') 85 | 86 | p.on('close', function () { 87 | connect = thunky(reconnect) 88 | }) 89 | 90 | p.on('status', function (status) { 91 | player.emit('status', status) 92 | }) 93 | 94 | cb(null, p) 95 | } 96 | }) 97 | }) 98 | 99 | var connectClient = thunky(function reconnectClient (cb) { 100 | var client = new castv2.Client() 101 | 102 | client.on('error', function () { 103 | connectClient = thunky(reconnectClient) 104 | }) 105 | 106 | client.on('close', function () { 107 | connectClient = thunky(reconnectClient) 108 | }) 109 | 110 | client.connect(player.host, function (err) { 111 | if (err) return cb(err) 112 | cb(null, client) 113 | }) 114 | }) 115 | 116 | player.name = cst.name 117 | player.host = cst.host 118 | 119 | player.client = function (cb) { 120 | connectClient(cb) 121 | } 122 | 123 | player.chromecastStatus = function (cb) { 124 | connectClient(function (err, client) { 125 | if (err) return cb(err) 126 | client.getStatus(cb) 127 | }) 128 | } 129 | 130 | player.play = function (url, opts, cb) { 131 | if (typeof opts === 'function') return player.play(url, null, opts) 132 | if (!opts) opts = {} 133 | if (!url) return player.resume(cb) 134 | if (!cb) cb = noop 135 | connect(function (err, p) { 136 | if (err) return cb(err) 137 | 138 | var media = { 139 | contentId: url, 140 | contentType: opts.type || mime.lookup(url, 'video/mp4'), 141 | streamType: opts.streamType || 'BUFFERED', 142 | tracks: [].concat(opts.subtitles || []).map(toSubtitles), 143 | textTrackStyle: opts.textTrackStyle, 144 | metadata: opts.metadata || { 145 | type: 0, 146 | metadataType: 0, 147 | title: opts.title || '', 148 | images: [].concat(opts.images || []).map(toMap) 149 | } 150 | } 151 | 152 | var autoSubtitles = opts.autoSubtitles 153 | if (autoSubtitles === false) autoSubtitles = 0 154 | if (autoSubtitles === true) autoSubtitles = 1 155 | 156 | var playerOptions = { 157 | autoplay: opts.autoPlay !== false, 158 | currentTime: opts.seek, 159 | activeTrackIds: opts.subtitles && (autoSubtitles === 0 ? [] : [autoSubtitles || 1]) 160 | } 161 | 162 | p.load(media, playerOptions, cb) 163 | }) 164 | } 165 | 166 | player.resume = function (cb) { 167 | if (!cb) cb = noop 168 | connect(function (err, p) { 169 | if (err) return cb(err) 170 | p.play(cb) 171 | }) 172 | } 173 | 174 | player.pause = function (cb) { 175 | if (!cb) cb = noop 176 | connect(function (err, p) { 177 | if (err) return cb(err) 178 | p.pause(cb) 179 | }) 180 | } 181 | 182 | player.stop = function (cb) { 183 | if (!cb) cb = noop 184 | connect(function (err, p) { 185 | if (err) return cb(err) 186 | p.stop(cb) 187 | }) 188 | } 189 | 190 | player.status = function (cb) { 191 | connect(function (err, p) { 192 | if (err) return cb(err) 193 | p.getStatus(cb) 194 | }) 195 | } 196 | 197 | player.subtitles = function (id, cb) { 198 | if (!cb) cb = noop 199 | connect(function (err, p) { 200 | if (err) return cb(err) 201 | 202 | player.request({ 203 | type: 'EDIT_TRACKS_INFO', 204 | activeTrackIds: id ? [id === true ? 1 : id] : [] 205 | }, cb) 206 | }) 207 | } 208 | 209 | player.volume = function (vol, cb) { 210 | if (!cb) cb = noop 211 | connect(function (err, p) { 212 | if (err) return cb(err) 213 | 214 | player.request({ 215 | type: 'SET_VOLUME', 216 | volume: vol === 0 ? { muted: true } : { level: vol, muted: false } 217 | }, cb) 218 | }) 219 | } 220 | 221 | player.request = function (data, cb) { 222 | if (!cb) cb = noop 223 | connect(function (err, p) { 224 | if (err) return cb(err) 225 | p.media.sessionRequest(data, cb) 226 | }) 227 | } 228 | 229 | player.seek = function (time, cb) { 230 | if (!cb) cb = noop 231 | connect(function (err, p) { 232 | if (err) return cb(err) 233 | p.seek(time, cb) 234 | }) 235 | } 236 | 237 | that.players.push(player) 238 | that.emit('update', player) 239 | } 240 | 241 | dns.on('response', function (response) { 242 | response.answers.forEach(function (a) { 243 | if (a.type === 'PTR' && a.name === '_googlecast._tcp.local') { 244 | var name = a.data 245 | var shortname = a.data.replace('._googlecast._tcp.local', '') 246 | if (!casts[name]) casts[name] = {name: shortname, host: null} 247 | } 248 | }) 249 | 250 | var onanswer = function (a) { 251 | debug('got answer %j', a) 252 | 253 | var name = a.name 254 | if (a.type === 'SRV' && casts[name] && !casts[name].host) { 255 | casts[name].host = a.data.target 256 | emit(casts[name]) 257 | } 258 | 259 | if (a.type === 'TXT' && casts[name]) { 260 | const text = {} 261 | a.data.forEach((item) => { 262 | const decodedItem = txt.decode(item) 263 | Object.keys(decodedItem).forEach((key) => { 264 | text[key] = decodedItem[key] 265 | }) 266 | }) 267 | 268 | const friendlyName = text.fn || text.n 269 | if (text.fn) { 270 | casts[name].name = text.fn 271 | emit(casts[name]) 272 | } 273 | } 274 | } 275 | 276 | response.additionals.forEach(onanswer) 277 | response.answers.forEach(onanswer) 278 | }) 279 | 280 | if (ssdp) { 281 | ssdp.on('response', function (headers, statusCode, info) { 282 | if (!headers.LOCATION) return 283 | 284 | get.concat(headers.LOCATION, function (err, res, body) { 285 | if (err) return 286 | parseString(body.toString(), {explicitArray: false, explicitRoot: false}, 287 | function (err, service) { 288 | if (err) return 289 | if (!service.device) return 290 | if (service.device.manufacturer !== 'Google Inc.') return 291 | 292 | debug('device %j', service.device) 293 | 294 | var name = service.device.friendlyName 295 | 296 | if (!name) return 297 | 298 | var host = url.parse(service.URLBase).hostname 299 | 300 | if (!casts[name]) { 301 | casts[name] = {name: name, host: host} 302 | return emit(casts[name]) 303 | } 304 | 305 | if (casts[name] && !casts[name].host) { 306 | casts[name].host = host 307 | emit(casts[name]) 308 | } 309 | }) 310 | }) 311 | }) 312 | } 313 | 314 | that.update = function () { 315 | debug('querying mdns and ssdp') 316 | if (ssdp) ssdp.search('urn:dial-multiscreen-org:device:dial:1') 317 | dns.query('_googlecast._tcp.local', 'PTR') 318 | } 319 | 320 | that.destroy = function () { 321 | dns.destroy() 322 | } 323 | 324 | that.update() 325 | 326 | return that 327 | } 328 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chromecasts", 3 | "version": "1.10.2", 4 | "description": "Query your local network for Chromecasts and have them play media", 5 | "main": "index.js", 6 | "dependencies": { 7 | "castv2-client": "^1.1.0", 8 | "debug": "^2.1.3", 9 | "dns-txt": "^2.0.2", 10 | "mime": "^1.3.4", 11 | "multicast-dns": "^7.2.4", 12 | "simple-get": "^2.0.0", 13 | "thunky": "^0.1.0", 14 | "xml2js": "^0.4.8" 15 | }, 16 | "optionalDependencies": { 17 | "node-ssdp": "^2.2.0" 18 | }, 19 | "devDependencies": { 20 | "standard": "*" 21 | }, 22 | "scripts": { 23 | "test": "standard" 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/mafintosh/chromecasts.git" 28 | }, 29 | "author": "Mathias Buus (@mafintosh)", 30 | "license": "MIT", 31 | "bugs": { 32 | "url": "https://github.com/mafintosh/chromecasts/issues" 33 | }, 34 | "homepage": "https://github.com/mafintosh/chromecasts" 35 | } 36 | --------------------------------------------------------------------------------