├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── app.js ├── chromecast.png ├── icon.icns ├── icon.ico ├── index.css ├── index.html ├── index.js ├── info.plist ├── mouseidle.js ├── package.json ├── player.js ├── playlist.js └── splash.gif /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | test.mp4 3 | Playback-darwin-x64 4 | Playback-win32 5 | Playback-win32.zip 6 | Playback.app 7 | Playback.app.zip 8 | .DS_Store 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # how to contribute? 2 | 3 | playback is an video player build using [electron](https://github.com/atom/electron) (formerly known as atom-shell) and [io.js](https://iojs.org/en/index.html). first you should install latest node.js or io.js (which installs npm). 4 | the easiest way to get started with development is to first clone this git repo and run 5 | 6 | ``` 7 | npm install 8 | npm start video.mp4 # or some other .mp4 file you have laying around 9 | ``` 10 | 11 | this will open playback in development mode and it should start playing video.mp4. 12 | the main entrypoint to the application is `app.js` which is executed by atom-shell. 13 | this file spawns.js a webview that is run with `index.html` which includes `index.js`. 14 | 15 | # i want to contribute but don't know where to start 16 | 17 | dont worry! just go to the issue page and find an issue marked "help wanted". 18 | these issues are normally well suited for people looking to help out in one way or the other 19 | -------------------------------------------------------------------------------- /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 | 2 | # Playback 3 | 4 | #### Video player built using [electron](http://electron.atom.io/) and [node.js](https://nodejs.org/) 5 | 6 | ## Features 7 | 8 | - Plays .MP4 and .WebM videos 9 | - Streaming to Chromecast 10 | - Streaming from http links, torrent magnet links, and IPFS links 11 | - [WebTorrent](https://webtorrent.io/) support – can torrent from/to WebRTC peers ("web peers") 12 | 13 | ## Installation 14 | 15 | To install it download the [latest release](https://github.com/mafintosh/playback/releases/latest) for your platform. 16 | 17 | ## Currently supported releases: 18 | 19 | * OS X 20 | * Windows 21 | * Linux (not supported yet) 22 | 23 | Pull requests are welcome that adds builds for other platforms. 24 | 25 | If you think it is missing a feature or you've found a bug feel free to open an issue, or even better sending a PR that fixes that. 26 | 27 | ## Development 28 | 29 | Simply clone this repo and run `npm install` and then `npm run rebuild`. 30 | Afterwards you can run `npm start` to run the app. 31 | 32 | ## License 33 | 34 | MIT 35 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env electron 2 | 3 | var app = require('app') 4 | var BrowserWindow = require('browser-window') 5 | var path = require('path') 6 | var ipc = require('electron').ipcMain 7 | var dialog = require('dialog') 8 | var shell = require('shell') 9 | var powerSaveBlocker = require('electron').powerSaveBlocker 10 | var globalShortcut = require('electron').globalShortcut 11 | 12 | var win 13 | var link 14 | var ready = false 15 | 16 | var onopen = function (e, lnk) { 17 | e.preventDefault() 18 | 19 | if (ready) { 20 | win.send('add-to-playlist', [].concat(lnk)) 21 | return 22 | } 23 | 24 | link = lnk 25 | } 26 | 27 | app.on('open-file', onopen) 28 | app.on('open-url', onopen) 29 | 30 | var frame = process.platform === 'win32' 31 | 32 | app.on('ready', function () { 33 | win = new BrowserWindow({ 34 | title: 'playback', 35 | width: 860, 36 | height: 470, 37 | frame: frame, 38 | show: false, 39 | transparent: true 40 | }) 41 | 42 | win.loadURL('file://' + path.join(__dirname, 'index.html#' + JSON.stringify(process.argv.slice(2)))) 43 | 44 | ipc.on('close', function () { 45 | app.quit() 46 | }) 47 | 48 | ipc.on('open-file-dialog', function () { 49 | var files = dialog.showOpenDialog({ properties: [ 'openFile', 'multiSelections' ]}) 50 | if (files) { 51 | files.forEach(app.addRecentDocument) 52 | win.send('add-to-playlist', files) 53 | } 54 | }) 55 | 56 | ipc.on('open-url-in-external', function (event, url) { 57 | shell.openExternal(url) 58 | }) 59 | 60 | ipc.on('focus', function () { 61 | win.focus() 62 | }) 63 | 64 | ipc.on('minimize', function () { 65 | win.minimize() 66 | }) 67 | 68 | ipc.on('maximize', function () { 69 | win.maximize() 70 | }) 71 | 72 | ipc.on('resize', function (e, message) { 73 | if (win.isMaximized()) return 74 | var wid = win.getSize()[0] 75 | var hei = (wid / message.ratio) | 0 76 | win.setSize(wid, hei) 77 | }) 78 | 79 | ipc.on('enter-full-screen', function () { 80 | win.setFullScreen(true) 81 | }) 82 | 83 | ipc.on('exit-full-screen', function () { 84 | win.setFullScreen(false) 85 | win.show() 86 | }) 87 | 88 | ipc.on('ready', function () { 89 | ready = true 90 | if (link) win.send('add-to-playlist', [].concat(link)) 91 | win.show() 92 | }) 93 | 94 | ipc.on('prevent-sleep', function () { 95 | app.sleepId = powerSaveBlocker.start('prevent-display-sleep') 96 | }) 97 | 98 | ipc.on('allow-sleep', function () { 99 | powerSaveBlocker.stop(app.sleepId) 100 | }) 101 | 102 | globalShortcut.register('MediaPlayPause', function () { 103 | win.send('media-play-pause') 104 | }) 105 | 106 | globalShortcut.register('MediaNextTrack', function () { 107 | win.send('media-next-track') 108 | }) 109 | 110 | globalShortcut.register('MediaPreviousTrack', function () { 111 | win.send('media-previous-track') 112 | }) 113 | 114 | }) 115 | 116 | app.on('will-quit', function () { 117 | 118 | globalShortcut.unregisterAll() 119 | 120 | }) 121 | -------------------------------------------------------------------------------- /chromecast.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mafintosh/playback/edf30bd3b99a13880f648de8a8553fdeec225ee0/chromecast.png -------------------------------------------------------------------------------- /icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mafintosh/playback/edf30bd3b99a13880f648de8a8553fdeec225ee0/icon.icns -------------------------------------------------------------------------------- /icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mafintosh/playback/edf30bd3b99a13880f648de8a8553fdeec225ee0/icon.ico -------------------------------------------------------------------------------- /index.css: -------------------------------------------------------------------------------- 1 | body, html { 2 | margin: 0; 3 | padding: 0; 4 | width: 100%; 5 | height: 100%; 6 | background-color: #000000; 7 | overflow: hidden; 8 | -webkit-user-select: none; 9 | font-family: 'Roboto', sans-serif; 10 | font-size: 12px; 11 | cursor: default; 12 | } 13 | 14 | .right { 15 | float: right; 16 | } 17 | 18 | .hidden { 19 | display: none; 20 | } 21 | 22 | #player { 23 | width: 100%; 24 | height: 100%; 25 | } 26 | 27 | #drag-video { 28 | z-index: 99; 29 | position: absolute; 30 | top: 24px; 31 | bottom: 50px; 32 | left: 0; 33 | right: 0; 34 | } 35 | 36 | #splash { 37 | width: 100%; 38 | height: 100%; 39 | background-image: url('splash.gif'); 40 | background-size: 100%; 41 | background-position: -50%; 42 | background-repeat: no-repeat; 43 | -webkit-filter: grayscale(100%); 44 | } 45 | 46 | #overlay { 47 | opacity: 0; 48 | z-index: 10; 49 | position: absolute; 50 | top: 0; 51 | left: 0; 52 | right: 0; 53 | bottom: 0; 54 | transition: opacity 0.3s ease; 55 | } 56 | 57 | #popup { 58 | opacity: 0; 59 | display: none; 60 | position: absolute; 61 | z-index: 20; 62 | right: 5px; 63 | bottom: 55px; 64 | top: 100px; 65 | width: 400px; 66 | background-color: #1F2021; 67 | border-radius: 3px; 68 | font-size: 14px; 69 | transition: opacity 0.3s ease; 70 | } 71 | 72 | #popup.chromecast #chromecast-popup { 73 | display: block; 74 | } 75 | 76 | #popup.chromecast #playlist-popup { 77 | display: none; 78 | } 79 | 80 | #popup.playlist #playlist-popup { 81 | display: block; 82 | } 83 | 84 | #popup.playlist #chromecast-popup { 85 | display: none; 86 | } 87 | 88 | #playlist-entries, #chromecast-entries { 89 | position: absolute; 90 | top: 46px; 91 | bottom: 55px; 92 | right: 0; 93 | left: 0; 94 | overflow: auto; 95 | } 96 | 97 | .playlist-entry, .chromecast-entry { 98 | color: #fff; 99 | font-size: 13px; 100 | padding: 10px 15px; 101 | overflow: hidden; 102 | text-overflow: ellipsis; 103 | white-space: nowrap; 104 | } 105 | 106 | @-webkit-keyframes spin { 107 | to { -webkit-transform: rotate(360deg); } 108 | } 109 | 110 | @-moz-keyframes spin { 111 | to { -moz-transform: rotate(360deg); } 112 | } 113 | 114 | @-ms-keyframes spin { 115 | to { -ms-transform: rotate(360deg); } 116 | } 117 | 118 | @-o-keyframes spin { 119 | to { -o-transform: rotate(360deg); } 120 | } 121 | 122 | @keyframes spin { 123 | to { transform: rotate(360deg); } 124 | } 125 | 126 | .playlist-entry .status { 127 | float: right; 128 | -webkit-animation: spin 1.5s infinite linear; 129 | -moz-animation: spin 1.5s infinite linear; 130 | -ms-animation: spin 1.5s infinite linear; 131 | -o-animation: spin 1.5s infinite linear; 132 | animation: spin 1.5s infinite linear; 133 | } 134 | 135 | .playlist-entry .status:before { 136 | font-size: 14px; 137 | } 138 | 139 | .playlist-entry .status:after { 140 | -webkit-transform: rotate(45deg); 141 | -moz-transform: rotate(45deg); 142 | -ms-transform: rotate(45deg); 143 | -o-transform: rotate(45deg); 144 | transform: rotate(45deg); 145 | } 146 | 147 | .playlist-entry.odd, .chromecast-entry.odd { 148 | background-color: #222324; 149 | } 150 | 151 | .playlist-entry.selected, .chromecast-entry.selected { 152 | background-color: #31A357; 153 | } 154 | 155 | #popup .header { 156 | background-color: #363738; 157 | color: #E1E1E1; 158 | font-size: 16px; 159 | line-height: 16px; 160 | padding: 15px; 161 | border-radius: 3px 3px 0 0; 162 | } 163 | 164 | #popup .button { 165 | margin: 10px; 166 | background-color: #31A357; 167 | padding: 10px; 168 | text-align: center; 169 | color: #E1E1E1; 170 | border-radius: 3px; 171 | } 172 | 173 | #controls-timeline-tooltip { 174 | background: #1F2021; 175 | border-radius: 3px; 176 | box-shadow: 0px 1px 2px rgba(0,0,0,.2); 177 | padding: 4px 8px; 178 | color: #fff; 179 | text-align: center; 180 | font-size: 11px; 181 | position: absolute; 182 | bottom: 53px; 183 | z-index: 100; 184 | opacity: 0; 185 | transition: opacity 0.25s; 186 | } 187 | 188 | #controls-timeline-tooltip:after { 189 | width: 0; 190 | height: 0; 191 | top: 100%; 192 | left: 50%; 193 | border-left: 6px solid transparent; 194 | border-right: 6px solid transparent; 195 | border-top: 6px solid #1F2021; 196 | content: ""; 197 | margin-left: -6px; 198 | position: absolute; 199 | } 200 | 201 | #popup .button.bottom { 202 | position: absolute; 203 | bottom: 0; 204 | left: 0; 205 | right: 0; 206 | } 207 | 208 | #idle { 209 | position: absolute; 210 | top: 24px; 211 | bottom: 50px; 212 | left: 0; 213 | right: 0; 214 | } 215 | 216 | .hide-cursor { 217 | cursor: none; 218 | } 219 | 220 | .hide-cursor #overlay { 221 | opacity: 0 !important; 222 | } 223 | 224 | body:hover #overlay, body:hover .titlebar { 225 | opacity: 1; 226 | } 227 | 228 | .titlebar { 229 | background-color: #1F2021; 230 | } 231 | 232 | #controls { 233 | z-index: 11; 234 | position: absolute; 235 | left: 0; 236 | right: 0; 237 | bottom: 0; 238 | height: 50px; 239 | background-color: #1F2021; 240 | color: #727374; 241 | } 242 | 243 | #controls .center { 244 | margin-top: 13px; 245 | } 246 | 247 | #controls-timeline { 248 | background-color: #303233; 249 | height: 10px; 250 | width: 100%; 251 | } 252 | 253 | #controls-timeline-position { 254 | background-color: #31A357; 255 | width: 0%; 256 | height: 10px; 257 | transition: width 0.25s linear; 258 | } 259 | 260 | .controls-secondary { 261 | padding: 6px 10px 0 0; 262 | } 263 | 264 | #player-downloadspeed, #controls-playlist, #controls-chromecast { 265 | margin-right: 11px; 266 | } 267 | 268 | #controls-play { 269 | margin: 6px 5px 6px 14px; 270 | } 271 | 272 | #controls-repeat { 273 | margin: 6px 0 6px 5px; 274 | min-width: 40px; 275 | } 276 | 277 | #controls-repeat.repeating .mega-ion:before, 278 | #controls-repeat.repeating .mega-ion:after { 279 | color: #31A357; 280 | } 281 | 282 | #controls-repeat.one .mega-ion:after { 283 | content: '1'; 284 | padding-left: 2px; 285 | } 286 | 287 | #controls-play, #controls-repeat, #player-downloadspeed, #controls-fullscreen, #controls-playlist, #controls-chromecast { 288 | float: left; 289 | } 290 | 291 | #controls-play .mega-ion, #player-downloadspeed .mega-ion, 292 | #controls-fullscreen .mega-ion, #controls-playlist .mega-ion { 293 | /* this is the click buffer */ 294 | padding: 3px 6px; 295 | } 296 | 297 | #controls-play span:hover .mega-ion, #player-downloadspeed span:hover .mega-ion, 298 | #controls-fullscreen span:hover .mega-ion, #controls-playlist span:hover .mega-ion, 299 | #controls-chromecast span:hover .mega-ion { 300 | color: #31A357; 301 | } 302 | 303 | #controls-chromecast .chromecast { 304 | background-image: url('chromecast.png'); 305 | background-size: 26px 72px; 306 | background-repeat: no-repeat; 307 | background-position: 0px 0px; 308 | margin-top: 6px; 309 | display: block; 310 | width: 26px; 311 | height: 18px; 312 | } 313 | #controls-chromecast .chromecast:hover, #controls-chromecast.selected .chromecast { 314 | background-position: 0px -18px; 315 | } 316 | .chromecasting #controls-chromecast .chromecast { 317 | background-position: 0px -36px; 318 | } 319 | .chromecasting #controls-chromecast .chromecast:hover, .chromecasting #controls-chromecast.selected .chromecast { 320 | background-position: 0px -54px; 321 | } 322 | 323 | #player-downloadspeed { 324 | margin-top: 4px; 325 | padding: 3px 20px; 326 | } 327 | 328 | #controls-playlist.selected .mega-ion { 329 | color: #31A357; 330 | } 331 | 332 | .mega-ion:before { 333 | color: #F0F0F0; 334 | font-size: 30px; 335 | } 336 | 337 | #controls-play .mega-ion { 338 | color: #31A357; 339 | } 340 | 341 | #controls-time { 342 | width: 100px; 343 | margin-left: 5px; 344 | float: left; 345 | } 346 | 347 | #controls-main { 348 | display: none; 349 | } 350 | 351 | #controls-time-current { 352 | color: #F0F0F0; 353 | } 354 | 355 | #controls-time-current, #controls-time-total { 356 | display: inline-block; 357 | min-width: 33px; 358 | } 359 | 360 | #controls-volume, 361 | #controls-pbrate { 362 | padding: 6px 5px; 363 | float: left; 364 | } 365 | 366 | #controls-volume .mega-ion, 367 | #controls-pbrate .mega-ion { 368 | display: inline-block; 369 | vertical-align: middle; 370 | } 371 | 372 | .slider { 373 | -webkit-appearance: none; 374 | width: 50px; 375 | height: 3px; 376 | border-radius: 0px; 377 | vertical-align: middle; 378 | } 379 | 380 | .slider::-webkit-slider-thumb { 381 | -webkit-appearance: none; 382 | background-color: #31A357; 383 | opacity: 1.0; 384 | width: 7px; 385 | height: 7px; 386 | border-radius: 3.5px; 387 | } 388 | 389 | .slider:focus { 390 | outline: none; 391 | } 392 | 393 | .hidden-slider .slider, 394 | .hidden-slider .slider::-webkit-slider-thumb { 395 | width: 0; 396 | transition: width 100ms; 397 | } 398 | 399 | .hidden-slider:hover .slider { 400 | width: 50px; 401 | } 402 | 403 | .hidden-slider:hover .slider::-webkit-slider-thumb { 404 | width: 7px; 405 | } 406 | 407 | #controls-name { 408 | float: left; 409 | margin-left: 20px; 410 | } 411 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | playback 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 |
14 | 25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | 35 |
36 |
37 | 38 |
39 |
40 | 41 | 42 |
43 |
44 | 45 | 46 |
47 |
48 | ‒‒:‒‒ 49 | / 50 | ‒‒:‒‒ 51 |
52 |
53 |
54 |
55 |
56 |
57 | 58 |
59 |
60 | 61 |
62 |
63 | 64 |
65 |
66 |
67 |
68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | var request = require('request') 2 | var drop = require('drag-and-drop-files') 3 | var mdns = require('multicast-dns')() 4 | var concat = require('concat-stream') 5 | var vtt = require('srt-to-vtt') 6 | var ipc = require('electron').ipcRenderer 7 | var remote = require('remote') 8 | var Menu = remote.require('menu') 9 | var MenuItem = remote.require('menu-item') 10 | var http = require('http') 11 | var rangeParser = require('range-parser') 12 | var pump = require('pump') 13 | var fs = require('fs') 14 | var eos = require('end-of-stream') 15 | var minimist = require('minimist') 16 | var JSONStream = require('JSONStream') 17 | var network = require('network-address') 18 | var chromecasts = require('chromecasts')() 19 | var $ = require('dombo') 20 | var titlebar = require('titlebar')() 21 | var clipboard = require('clipboard') 22 | var player = require('./player') 23 | var playlist = require('./playlist') 24 | var mouseidle = require('./mouseidle') 25 | 26 | var argv = minimist(JSON.parse(window.location.toString().split('#')[1]), { 27 | alias: {follow: 'f'}, 28 | boolean: ['follow'] 29 | }) 30 | 31 | var printError = function (err) { 32 | if (err) console.log(err) 33 | } 34 | 35 | var onsubs = function (data) { 36 | media.subtitles(data) 37 | } 38 | 39 | ipc.on('add-to-playlist', function (event, links) { 40 | links.forEach(function (link) { 41 | if (/\.(vtt|srt)$/i.test(link)) { 42 | fs.createReadStream(link).pipe(vtt()).pipe(concat(onsubs)) 43 | return 44 | } 45 | 46 | list.add(link, printError) 47 | }) 48 | }) 49 | 50 | $(document).on('paste', function (e) { 51 | ipc.emit('add-to-playlist', e.clipboardData.getData('text').split('\n')) 52 | }) 53 | 54 | var media = player($('#player')[0]) 55 | var list = playlist() 56 | 57 | if (process.platform !== 'win32') { 58 | titlebar.appendTo('#titlebar') 59 | } 60 | 61 | drop($('body')[0], function (files) { 62 | for (var i = 0; i < files.length; i++) { 63 | if (/\.(vtt|srt)$/i.test(files[i].path)) { 64 | fs.createReadStream(files[i].path).pipe(vtt()).pipe(concat(onsubs)) 65 | return 66 | } 67 | 68 | list.add(files[i].path, printError) 69 | } 70 | }) 71 | 72 | var videoDown = false 73 | var videoOffsets = [0, 0] 74 | 75 | $('#idle').on('mousedown', function (e) { 76 | videoDown = true 77 | videoOffsets = [e.clientX, e.clientY] 78 | }) 79 | 80 | $('#idle').on('mouseup', function () { 81 | videoDown = false 82 | }) 83 | 84 | $('#idle').on('mousemove', function (e) { 85 | if (videoDown) remote.getCurrentWindow().setPosition(e.screenX - videoOffsets[0], e.screenY - videoOffsets[1]) 86 | }) 87 | 88 | var onTop = false 89 | 90 | $(window).on('contextmenu', function (e) { 91 | e.preventDefault() 92 | videoDown = false 93 | 94 | var menu = new Menu() 95 | 96 | menu.append(new MenuItem({ 97 | label: 'Always on top', 98 | type: 'checkbox', 99 | checked: onTop, 100 | click: function () { 101 | onTop = !onTop 102 | remote.getCurrentWindow().setAlwaysOnTop(onTop) 103 | } 104 | })) 105 | 106 | menu.append(new MenuItem({ 107 | label: 'Paste link', 108 | click: function () { 109 | ipc.emit('add-to-playlist', clipboard.readText().split('\n')) 110 | } 111 | })) 112 | 113 | if (media.subtitles()) { 114 | menu.append(new MenuItem({ 115 | label: 'Remove subtitles', 116 | click: function () { 117 | media.subtitles(null) 118 | } 119 | })) 120 | } 121 | 122 | menu.popup(remote.getCurrentWindow()) 123 | }) 124 | 125 | $('body').on('mouseover', function () { 126 | if (onTop) ipc.send('focus') 127 | }) 128 | 129 | var isFullscreen = false 130 | 131 | var onfullscreentoggle = function (e) { 132 | if (!isFullscreen && e.shiftKey) { 133 | ipc.send('resize', { 134 | width: media.width, 135 | height: media.height, 136 | ratio: media.ratio 137 | }) 138 | return 139 | } 140 | 141 | var $icon = $('#controls-fullscreen .js-icon') 142 | if (isFullscreen) { 143 | isFullscreen = false 144 | $('#titlebar')[0].style.display = 'block' 145 | $icon.removeClass('ion-arrow-shrink') 146 | $icon.addClass('ion-arrow-expand') 147 | ipc.send('exit-full-screen') 148 | } else { 149 | isFullscreen = true 150 | $('#titlebar')[0].style.display = 'none' 151 | $icon.removeClass('ion-arrow-expand') 152 | $icon.addClass('ion-arrow-shrink') 153 | ipc.send('enter-full-screen') 154 | } 155 | } 156 | 157 | var onplaytoggle = function () { 158 | if (media.playing) media.pause() 159 | else media.play() 160 | } 161 | 162 | var onnexttrack = function () { 163 | var shouldLoop = true 164 | list.selectNext(shouldLoop) 165 | } 166 | 167 | var onprevioustrack = function () { 168 | var shouldLoop = true 169 | list.selectPrevious(shouldLoop) 170 | } 171 | 172 | var onrepeatcycle = function () { 173 | var $controlsRepeat = $('#controls-repeat') 174 | if (!list.repeating) { 175 | $controlsRepeat.addClass('repeating') 176 | list.repeat() 177 | return 178 | } 179 | 180 | if (!list.repeatingOne) { 181 | $controlsRepeat.addClass('one') 182 | list.repeatOne() 183 | return 184 | } 185 | 186 | $controlsRepeat.removeClass('repeating') 187 | $controlsRepeat.removeClass('one') 188 | list.unrepeat() 189 | } 190 | 191 | $('#idle').on('dblclick', onfullscreentoggle) 192 | $('#controls-fullscreen').on('click', onfullscreentoggle) 193 | 194 | $('#controls-timeline').on('click', function (e) { 195 | var time = e.pageX / $('#controls-timeline')[0].offsetWidth * media.duration 196 | media.time(time) 197 | }) 198 | 199 | function updateTimelineTooltip(e) { 200 | var tooltip = $('#controls-timeline-tooltip')[0] 201 | var percentage = e.pageX / $('#controls-timeline')[0].offsetWidth 202 | var time = formatTime(percentage * media.duration) 203 | tooltip.innerHTML = time 204 | tooltip.style.left = (e.pageX - tooltip.offsetWidth / 2) + "px" 205 | } 206 | 207 | $('#controls-timeline').on('mousemove', function (e) { 208 | updateTimelineTooltip(e) 209 | }) 210 | 211 | $('#controls-timeline').on('mouseover', function (e) { 212 | var tooltip = $('#controls-timeline-tooltip')[0] 213 | tooltip.style.opacity = 1 214 | updateTimelineTooltip(e) 215 | }) 216 | 217 | $('#controls-timeline').on('mouseout', function (e) { 218 | var tooltip = $('#controls-timeline-tooltip')[0] 219 | tooltip.style.opacity = 0 220 | }) 221 | 222 | var isVolumeSliderClicked = false 223 | var isPbrateSliderClicked = false 224 | 225 | function updateAudioVolume(value) { 226 | media.volume(value) 227 | } 228 | 229 | function updateVolumeSlider(volume) { 230 | var val = volume.value * 100 231 | volume.style.background = '-webkit-gradient(linear, left top, right top, color-stop(' + val.toString() + '%, #31A357), color-stop(' + val.toString() + '%, #727374))' 232 | } 233 | 234 | function updatePlaybackRate(value) { 235 | media.playbackRate(value) 236 | } 237 | 238 | function updatePlaybackRateSlider(volume) { 239 | var min = 0.5 240 | var max = 4 241 | var scaled = (volume.value - min) / (max - min) 242 | var val = scaled * 100 243 | volume.style.background = '-webkit-gradient(linear, left top, right top, color-stop(' + val.toString() + '%, #31A357), color-stop(' + val.toString() + '%, #727374))' 244 | } 245 | 246 | $('#controls-volume-slider').on('mousemove', function (e) { 247 | if (isVolumeSliderClicked) { 248 | var volume = $('#controls-volume-slider')[0] 249 | updateAudioVolume(volume.value) 250 | updateVolumeSlider(volume) 251 | } 252 | }) 253 | 254 | $('#controls-volume-slider').on('mousedown', function (e) { 255 | isVolumeSliderClicked = true 256 | }) 257 | 258 | $('#controls-volume-slider').on('mouseup', function (e) { 259 | var volume = $('#controls-volume-slider')[0] 260 | updateAudioVolume(volume.value) 261 | updateVolumeSlider(volume) 262 | isVolumeSliderClicked = false 263 | }) 264 | 265 | $('#controls-pbrate-slider').on('mousemove', function (e) { 266 | if (isPbrateSliderClicked) { 267 | var volume = $('#controls-pbrate-slider')[0] 268 | updatePlaybackRate(volume.value) 269 | updatePlaybackRateSlider(volume) 270 | } 271 | }) 272 | 273 | $('#controls-pbrate-slider').on('mousedown', function (e) { 274 | isPbrateSliderClicked = true 275 | }) 276 | 277 | $('#controls-pbrate-slider').on('mouseup', function (e) { 278 | var volume = $('#controls-pbrate-slider')[0] 279 | updatePlaybackRate(volume.value) 280 | updatePlaybackRateSlider(volume) 281 | isPbrateSliderClicked = false 282 | }) 283 | 284 | $(document).on('keydown', function (e) { 285 | if (e.keyCode === 27 && isFullscreen) return onfullscreentoggle(e) 286 | if (e.keyCode === 13 && e.metaKey) return onfullscreentoggle(e) 287 | if (e.keyCode === 13 && e.shiftKey) return onfullscreentoggle(e) 288 | if (e.keyCode === 32) return onplaytoggle(e) 289 | 290 | if ($('#controls-playlist').hasClass('selected')) $('#controls-playlist').trigger('click') 291 | if ($('#controls-chromecast').hasClass('selected')) $('#controls-chromecast').trigger('click') 292 | }) 293 | 294 | mouseidle($('#idle')[0], 3000, 'hide-cursor') 295 | 296 | list.on('select', function () { 297 | $('#controls-name')[0].innerText = list.selected.name 298 | media.play('http://127.0.0.1:' + server.address().port + '/' + list.selected.id) 299 | if (list.selected.subtitles) onsubs(list.selected.subtitles) 300 | updatePlaylist() 301 | }) 302 | 303 | var updatePlaylist = function () { 304 | var html = '' 305 | 306 | list.entries.forEach(function (entry, i) { 307 | html += '
' + 308 | '' + entry.name + '
' 309 | }) 310 | 311 | $('#playlist-entries')[0].innerHTML = html 312 | } 313 | 314 | var updateChromecast = function () { 315 | var html = '' 316 | 317 | chromecasts.players.forEach(function (player, i) { 318 | html += '
' + 319 | '' + player.name + '' 320 | }) 321 | 322 | $('#chromecast-entries')[0].innerHTML = html 323 | } 324 | 325 | chromecasts.on('update', updateChromecast) 326 | 327 | var updateSpeeds = function () { 328 | $('#player-downloadspeed')[0].innerText = '' 329 | list.entries.forEach(function (entry, i) { 330 | if (!entry.downloadSpeed) return 331 | 332 | $('.playlist-entry[data-index="' + i + '"] .status').addClass('ion-loop') 333 | 334 | var kilobytes = entry.downloadSpeed() / 1024 335 | var megabytes = kilobytes / 1024 336 | var text = megabytes > 1 ? megabytes.toFixed(1) + ' mb/s' : Math.floor(kilobytes) + ' kb/s' 337 | 338 | if (list.selected === entry) $('#player-downloadspeed')[0].innerText = text 339 | }) 340 | } 341 | setInterval(updateSpeeds, 750) 342 | 343 | list.on('update', updatePlaylist) 344 | 345 | list.once('update', function () { 346 | list.select(0) 347 | }) 348 | 349 | var popupSelected = function () { 350 | return $('#controls-playlist').hasClass('selected') || $('#controls-chromecast').hasClass('selected') 351 | } 352 | 353 | var closePopup = function (e) { 354 | if (e && (e.target === $('#controls-playlist .js-icon')[0] || e.target === $('#controls-chromecast .chromecast')[0])) return 355 | $('#popup')[0].style.opacity = 0 356 | $('#controls-playlist').removeClass('selected') 357 | $('#controls-chromecast').removeClass('selected') 358 | } 359 | 360 | $('#controls').on('click', closePopup) 361 | $('#idle').on('click', closePopup) 362 | 363 | $('#playlist-entries').on('click', '.playlist-entry', function (e) { 364 | var id = Number(this.getAttribute('data-id')) 365 | list.select(id) 366 | }) 367 | 368 | $('#chromecast-entries').on('click', '.chromecast-entry', function (e) { 369 | var id = Number(this.getAttribute('data-id')) 370 | var player = chromecasts.players[id] 371 | 372 | if (media.casting === player) { 373 | $('body').removeClass('chromecasting') 374 | media.chromecast(null) 375 | return updateChromecast() 376 | } 377 | 378 | $('body').addClass('chromecasting') 379 | media.chromecast(player) 380 | updateChromecast() 381 | }) 382 | 383 | var updatePopup = function () { 384 | if (popupSelected()) { 385 | $('#popup')[0].style.display = 'block' 386 | $('#popup')[0].style.opacity = 1 387 | } else { 388 | $('#popup')[0].style.opacity = 0 389 | } 390 | } 391 | 392 | $('#controls-chromecast').on('click', function (e) { 393 | if ($('#controls-chromecast').hasClass('selected')) { 394 | closePopup() 395 | return 396 | } 397 | 398 | $('#popup')[0].className = 'chromecast' 399 | $('#controls .controls-secondary .selected').removeClass('selected') 400 | $('#controls-chromecast').addClass('selected') 401 | chromecasts.update() 402 | updatePopup() 403 | }) 404 | 405 | $('#controls-playlist').on('click', function (e) { 406 | if ($('#controls-playlist').hasClass('selected')) { 407 | closePopup() 408 | return 409 | } 410 | 411 | $('#popup')[0].className = 'playlist' 412 | $('#controls .controls-secondary .selected').removeClass('selected') 413 | $('#controls-playlist').addClass('selected') 414 | updatePopup() 415 | }) 416 | 417 | $('#playlist-add-media').on('click', function () { 418 | ipc.send('open-file-dialog') 419 | }) 420 | 421 | $('#popup').on('transitionend', function () { 422 | if (!popupSelected()) $('#popup')[0].style.display = 'none' 423 | }) 424 | 425 | titlebar.on('close', function () { 426 | ipc.send('close') 427 | }) 428 | 429 | titlebar.on('minimize', function () { 430 | ipc.send('minimize') 431 | }) 432 | 433 | titlebar.on('maximize', function () { 434 | ipc.send('maximize') 435 | }) 436 | 437 | titlebar.on('fullscreen', onfullscreentoggle) 438 | 439 | var appmenu_template = [ 440 | { 441 | label: 'Playback', 442 | submenu: [ 443 | { 444 | label: 'About Playback', 445 | click: function() { ipc.send('open-url-in-external', 'https://mafintosh.github.io/playback/') } 446 | }, 447 | { 448 | type: 'separator' 449 | }, 450 | { 451 | label: 'Quit', 452 | accelerator: 'Command+Q', 453 | click: function() { ipc.send('close') } 454 | } 455 | ] 456 | }, 457 | { 458 | label: 'File', 459 | submenu: [ 460 | { 461 | label: 'Add media', 462 | accelerator: 'Command+O', 463 | click: function() { ipc.send('open-file-dialog') } 464 | }, 465 | { 466 | label: 'Add link from clipboard', 467 | accelerator: 'CommandOrControl+V', 468 | click: function () { ipc.emit('add-to-playlist', clipboard.readText().split('\n')) } 469 | } 470 | ] 471 | }, 472 | { 473 | label: 'Window', 474 | submenu: [ 475 | { 476 | label: 'Minimize', 477 | accelerator: 'Command+M', 478 | click: function() { ipc.send('minimize') } 479 | }, 480 | { 481 | label: 'Toggle Full Screen', 482 | accelerator: 'Command+Enter', 483 | click: onfullscreentoggle 484 | } 485 | ] 486 | }, 487 | { 488 | label: 'Help', 489 | submenu: [ 490 | { 491 | label: 'Report Issue', 492 | click: function() { ipc.send('open-url-in-external', 'https://github.com/mafintosh/playback/issues') } 493 | }, 494 | { 495 | label: 'View Source Code on GitHub', 496 | click: function() { ipc.send('open-url-in-external', 'https://github.com/mafintosh/playback') } 497 | }, 498 | { 499 | type: 'separator' 500 | }, 501 | { 502 | label: 'Releases', 503 | click: function() { ipc.send('open-url-in-external', 'https://github.com/mafintosh/playback/releases') } 504 | } 505 | ] 506 | } 507 | ] 508 | var appmenu = Menu.buildFromTemplate(appmenu_template) 509 | Menu.setApplicationMenu(appmenu) 510 | 511 | var formatTime = function (secs) { 512 | var hours = (secs / 3600) | 0 513 | var mins = ((secs - hours * 3600) / 60) | 0 514 | secs = (secs - (3600 * hours + 60 * mins)) | 0 515 | if (mins < 10) mins = '0' + mins 516 | if (secs < 10) secs = '0' + secs 517 | return (hours ? hours + ':' : '') + mins + ':' + secs 518 | } 519 | 520 | var updateInterval 521 | media.on('metadata', function () { 522 | // TODO: comment in again when not quirky 523 | // if (!isFullscreen) { 524 | // ipc.send('resize', { 525 | // width: media.width, 526 | // height: media.height, 527 | // ratio: media.ratio 528 | // }) 529 | // } 530 | 531 | $('#controls-main')[0].style.display = 'block' 532 | $('#controls-time-total')[0].innerText = formatTime(media.duration) 533 | $('#controls-time-current')[0].innerText = formatTime(media.time()) 534 | 535 | clearInterval(updateInterval) 536 | updateInterval = setInterval(function () { 537 | $('#controls-timeline-position')[0].style.width = (100 * (media.time() / media.duration)) + '%' 538 | $('#controls-time-current')[0].innerText = formatTime(media.time()) 539 | }, 250) 540 | }) 541 | 542 | $('#controls-play').on('click', onplaytoggle) 543 | $('#controls-repeat').on('click', onrepeatcycle) 544 | ipc.on('media-play-pause', onplaytoggle) 545 | ipc.on('media-next-track', onnexttrack) 546 | ipc.on('media-previous-track', onprevioustrack) 547 | 548 | media.on('end', function () { 549 | ipc.send('allow-sleep') 550 | list.selectNext() 551 | }) 552 | 553 | media.on('play', function () { 554 | ipc.send('prevent-sleep') 555 | $('#splash').toggleClass('hidden', !media.casting) 556 | $('#player').toggleClass('hidden', media.casting) 557 | $('#controls-play .js-icon').removeClass('ion-play') 558 | $('#controls-play .js-icon').addClass('ion-pause') 559 | }) 560 | 561 | media.on('pause', function () { 562 | ipc.send('allow-sleep') 563 | $('#controls-play .js-icon').removeClass('ion-pause') 564 | $('#controls-play .js-icon').addClass('ion-play') 565 | }) 566 | 567 | var server = http.createServer(function (req, res) { 568 | if (req.headers.origin) res.setHeader('Access-Control-Allow-Origin', req.headers.origin) 569 | 570 | if (req.url === '/subtitles') { 571 | var buf = media.subtitles() 572 | 573 | if (buf) { 574 | res.setHeader('Content-Type', 'text/vtt; charset=utf-8') 575 | res.setHeader('Content-Length', buf.length) 576 | res.end(buf) 577 | } else { 578 | res.statusCode = 404 579 | res.end() 580 | } 581 | } 582 | 583 | if (req.url === '/follow') { // TODO: do not hardcode /0 584 | if (!list.selected) return res.end() 585 | var stringify = JSONStream.stringify() 586 | 587 | var onseek = function () { 588 | stringify.write({type: 'seek', time: media.time() }) 589 | } 590 | 591 | var onsubs = function (data) { 592 | stringify.write({type: 'subtitles', data: data.toString('base64')}) 593 | } 594 | 595 | stringify.pipe(res) 596 | stringify.write({type: 'open', url: 'http://' + network() + ':' + server.address().port + '/' + list.selected.id, time: media.time() }) 597 | 598 | media.on('subtitles', onsubs) 599 | media.on('seek', onseek) 600 | eos(res, function () { 601 | media.removeListener('subtitles', onsubs) 602 | media.removeListener('seek', onseek) 603 | }) 604 | return 605 | } 606 | 607 | var id = Number(req.url.slice(1)) 608 | var file = list.get(id) 609 | 610 | if (!file) { 611 | res.statusCode = 404 612 | res.end() 613 | return 614 | } 615 | 616 | var range = req.headers.range && rangeParser(file.length, req.headers.range)[0] 617 | 618 | res.setHeader('Accept-Ranges', 'bytes') 619 | res.setHeader('Content-Type', 'video/mp4') 620 | 621 | if (!range) { 622 | res.setHeader('Content-Length', file.length) 623 | if (req.method === 'HEAD') return res.end() 624 | pump(file.createReadStream(), res) 625 | return 626 | } 627 | 628 | res.statusCode = 206 629 | res.setHeader('Content-Length', range.end - range.start + 1) 630 | res.setHeader('Content-Range', 'bytes ' + range.start + '-' + range.end + '/' + file.length) 631 | if (req.method === 'HEAD') return res.end() 632 | pump(file.createReadStream(range), res) 633 | }) 634 | 635 | server.listen(0, function () { 636 | console.log('Playback server running on port ' + server.address().port) 637 | 638 | argv._.forEach(function (file) { 639 | if (file) list.add(file, printError) 640 | }) 641 | 642 | if (argv.follow) { 643 | mdns.on('response', function onresponse(response) { 644 | response.answers.forEach(function (a) { 645 | if (a.name !== 'playback') return 646 | clearInterval(interval) 647 | mdns.removeListener('response', onresponse) 648 | 649 | var host = a.data.target + ':' + a.data.port 650 | 651 | request('http://' + host + '/follow').pipe(JSONStream.parse('*')).on('data', function (data) { 652 | if (data.type === 'open') { 653 | media.play(data.url) 654 | media.time(data.time) 655 | } 656 | 657 | if (data.type === 'seek') { 658 | media.time(data.time) 659 | } 660 | 661 | if (data.type === 'subtitles') { 662 | media.subtitles(data.data) 663 | } 664 | }) 665 | }) 666 | }) 667 | 668 | var query = function () { 669 | mdns.query({ 670 | questions: [{ 671 | name: 'playback', 672 | type: 'SRV' 673 | }] 674 | }) 675 | } 676 | 677 | var interval = setInterval(query, 5000) 678 | query() 679 | } else { 680 | mdns.on('query', function (query) { 681 | var valid = query.questions.some(function (q) { 682 | return q.name === 'playback' 683 | }) 684 | 685 | if (!valid) return 686 | 687 | mdns.respond({ 688 | answers: [{ 689 | type: 'SRV', 690 | ttl: 5, 691 | name: 'playback', 692 | data: {port: server.address().port, target: network()} 693 | }] 694 | }) 695 | }) 696 | } 697 | 698 | setTimeout(function () { 699 | ipc.send('ready') 700 | }, 10) 701 | }) 702 | 703 | var volumeSlider = $('#controls-volume-slider')[0] 704 | volumeSlider.setAttribute("value", 0.5) 705 | volumeSlider.setAttribute("min", 0) 706 | volumeSlider.setAttribute("max", 1) 707 | volumeSlider.setAttribute("step", 0.05) 708 | updateAudioVolume(0.5) 709 | updateVolumeSlider(volumeSlider) 710 | 711 | var pbrateSlider = $('#controls-pbrate-slider')[0] 712 | pbrateSlider.setAttribute("value", 1) 713 | pbrateSlider.setAttribute("min", 0.5) 714 | pbrateSlider.setAttribute("max", 4) 715 | pbrateSlider.setAttribute("step", 0.25) 716 | updatePlaybackRate(1) 717 | updatePlaybackRateSlider(pbrateSlider) 718 | -------------------------------------------------------------------------------- /info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildMachineOSBuild 6 | 14D136 7 | CFBundleDisplayName 8 | Playback 9 | CFBundleExecutable 10 | Electron 11 | CFBundleIconFile 12 | atom.icns 13 | CFBundleIdentifier 14 | com.electron.playback 15 | CFBundleInfoDictionaryVersion 16 | 6.0 17 | CFBundleName 18 | Playback 19 | CFBundlePackageType 20 | APPL 21 | CFBundleVersion 22 | 0.25.3 23 | DTSDKBuild 24 | 14D125 25 | DTSDKName 26 | macosx 27 | DTXcode 28 | 0631 29 | DTXcodeBuild 30 | 6D1002 31 | LSMinimumSystemVersion 32 | 10.8.0 33 | NSMainNibFile 34 | MainMenu 35 | NSPrincipalClass 36 | AtomApplication 37 | NSSupportsAutomaticGraphicsSwitching 38 | 39 | CFBundleURLTypes 40 | 41 | 42 | CFBundleURLName 43 | BitTorrent Magnet URL 44 | CFBundleURLSchemes 45 | 46 | magnet 47 | 48 | 49 | 50 | CFBundleURLName 51 | Playback URL 52 | CFBundleURLSchemes 53 | 54 | playback 55 | 56 | 57 | 58 | CFBundleDocumentTypes 59 | 60 | 61 | CFBundleTypeIconFile 62 | MPEG-4.icns 63 | CFBundleTypeRole 64 | Viewer 65 | ICExtension 66 | MPEG4 67 | LSHandlerRank 68 | Default 69 | LSItemContentTypes 70 | 71 | public.mpeg-4 72 | com.apple.protected-mpeg-4-video 73 | 74 | NSDocumentClass 75 | MGPlaybackDocument 76 | 77 | 78 | CFBundleTypeExtensions 79 | 80 | webm 81 | 82 | CFBundleTypeIconFile 83 | document.icns 84 | CFBundleTypeMIMETypes 85 | 86 | video/webm 87 | 88 | CFBundleTypeName 89 | HTML5 Video (WebM) 90 | CFBundleTypeRole 91 | Viewer 92 | 93 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /mouseidle.js: -------------------------------------------------------------------------------- 1 | var $ = require('dombo') 2 | 3 | module.exports = function (elem, timeout, className) { 4 | var max = (timeout / 250) | 0 5 | var overMovie = false 6 | var hiding = false 7 | var moving = 0 8 | var tick = 0 9 | var mousedown = false 10 | 11 | var update = function () { 12 | if (hiding) { 13 | $('body').removeClass(className) 14 | hiding = false 15 | } 16 | } 17 | 18 | $(elem).on('mouseover', function () { 19 | overMovie = true 20 | update() 21 | }) 22 | 23 | $(elem).on('mouseout', function () { 24 | overMovie = false 25 | }) 26 | 27 | $(elem).on('mousedown', function (e) { 28 | mousedown = true 29 | moving = tick 30 | update() 31 | }) 32 | 33 | $(elem).on('mouseup', function (e) { 34 | mousedown = false 35 | moving = tick 36 | }) 37 | 38 | $(window).on('mousemove', function (e) { 39 | moving = tick 40 | update() 41 | }) 42 | 43 | setInterval(function () { 44 | tick++ 45 | if (!overMovie) return 46 | if (tick - moving < max || mousedown) return 47 | hiding = true 48 | $('body').addClass(className) 49 | }, 250) 50 | } 51 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "playback", 3 | "version": "1.6.0", 4 | "description": "Video player built using electron and node.js", 5 | "main": "app.js", 6 | "dependencies": { 7 | "JSONStream": "^0.10.0", 8 | "chromecasts": "^1.2.1", 9 | "concat-stream": "^1.4.7", 10 | "dombo": "^3.2.0", 11 | "drag-and-drop-files": "0.0.1", 12 | "duplexify": "^3.2.0", 13 | "end-of-stream": "^1.1.0", 14 | "filereader-stream": "^0.2.0", 15 | "ionicons": "^2.0.1", 16 | "minimist": "^1.1.1", 17 | "multicast-dns": "^2.0.0", 18 | "network-address": "^1.0.0", 19 | "pump": "^1.0.0", 20 | "range-parser": "^1.0.2", 21 | "request": "^2.54.0", 22 | "roboto-fontface": "^0.4.2", 23 | "srt-to-vtt": "^1.0.2", 24 | "titlebar": "^1.1.0", 25 | "webtorrent": "^0.63.3", 26 | "ytdl-core": "^0.5.1" 27 | }, 28 | "devDependencies": { 29 | "electron-packager": "^5.1.1", 30 | "electron-prebuilt": "0.35.4" 31 | }, 32 | "bin": { 33 | "playback": "./app.js" 34 | }, 35 | "scripts": { 36 | "rebuild": "npm rebuild --runtime=electron --target=0.35.4 --disturl=https://atom.io/download/atom-shell", 37 | "start": "electron app.js", 38 | "dev": "electron app.js test.mp4", 39 | "mac-bundle": "electron-packager . Playback --platform=darwin --arch=x64 --version=0.35.4 --ignore=node_modules/electron-prebuilt && cp info.plist Playback-darwin-x64/Playback.app/Contents/Info.plist && cp icon.icns Playback-darwin-x64/Playback.app/Contents/Resources/atom.icns", 40 | "win-bundle": "electron-packager . Playback --platform=win32 --arch=ia32 --version=0.35.4 --icon=icon.ico", 41 | "linux-64-bundle": "electron-packager . Playback --platform=linux --arch=x64 --version=0.35.4 ignore='node_modules/(electron-packager|electron-prebuilt)'" 42 | }, 43 | "repository": { 44 | "type": "git", 45 | "url": "https://github.com/mafintosh/playback.git" 46 | }, 47 | "author": "Mathias Buus (@mafintosh)", 48 | "license": "MIT", 49 | "bugs": { 50 | "url": "https://github.com/mafintosh/playback/issues" 51 | }, 52 | "homepage": "https://github.com/mafintosh/playback" 53 | } 54 | -------------------------------------------------------------------------------- /player.js: -------------------------------------------------------------------------------- 1 | var events = require('events') 2 | var network = require('network-address') 3 | 4 | module.exports = function ($video) { 5 | var that = new events.EventEmitter() 6 | var atEnd = false 7 | var lastUrl = null 8 | 9 | that.setMaxListeners(0) 10 | 11 | that.width = 0 12 | that.height = 0 13 | that.element = $video 14 | 15 | var chromecast = null 16 | var chromecastTime = 0 17 | var chromecastOffset = 0 18 | var chromecastSubtitles = 1 19 | var interval = null 20 | 21 | var onerror = function () { 22 | if (chromecast) chromecast.removeListener('error', onerror) 23 | that.chromecast(null) 24 | } 25 | 26 | var onmetadata = function (err, status) { 27 | if (err) return onerror(err) 28 | if (chromecastTime) chromecastOffset = 0 29 | chromecastTime = status.currentTime 30 | chromecastSubtitles = 1 31 | that.duration = status.media.duration 32 | that.emit('metadata') 33 | 34 | clearInterval(interval) 35 | interval = setInterval(function () { 36 | chromecast.status(function (err, status) { 37 | if (err) return onerror(err) 38 | 39 | if (!status) { 40 | chromecastOffset = 0 41 | clearInterval(interval) 42 | atEnd = true 43 | that.playing = false 44 | that.emit('pause') 45 | that.emit('end') 46 | return 47 | } 48 | 49 | if (chromecastTime) chromecastOffset = 0 50 | chromecastTime = status.currentTime 51 | }) 52 | }, 1000) 53 | } 54 | 55 | that.casting = false 56 | that.chromecast = function (player) { 57 | chromecastOffset = chromecast ? 0 : $video.currentTime 58 | clearInterval(interval) 59 | if (chromecast && that.playing) chromecast.stop() 60 | chromecast = player 61 | that.casting = player 62 | if (chromecast) chromecast.on('error', onerror) 63 | if (!that.playing) return 64 | media.play(lastUrl, that.casting ? chromecastOffset : chromecastTime) 65 | } 66 | 67 | $video.addEventListener('seeked', function () { 68 | if (chromecast) return 69 | that.emit('seek') 70 | }, false) 71 | 72 | $video.addEventListener('ended', function () { 73 | if (chromecast) return 74 | atEnd = true 75 | that.playing = false 76 | that.emit('pause') 77 | that.emit('end') 78 | }, false) 79 | 80 | $video.addEventListener('loadedmetadata', function () { 81 | if (chromecast) return 82 | that.width = $video.videoWidth 83 | that.height = $video.videoHeight 84 | that.ratio = that.width / that.height 85 | that.duration = $video.duration 86 | that.emit('metadata') 87 | }, false) 88 | 89 | that.time = function (time) { 90 | atEnd = false 91 | if (chromecast) { 92 | if (arguments.length) { 93 | chromecastOffset = 0 94 | chromecast.seek(time) 95 | } 96 | return chromecastOffset || chromecastTime 97 | } 98 | if (arguments.length) $video.currentTime = time 99 | return $video.currentTime 100 | } 101 | 102 | that.playing = false 103 | 104 | that.play = function (url, time) { 105 | if (!url && !lastUrl) return 106 | var changed = url && lastUrl !== url 107 | if (changed) subs = null 108 | if (chromecast) { 109 | $video.innerHTML = '' // clear 110 | $video.pause() 111 | $video.load() 112 | if (url) lastUrl = url 113 | else url = lastUrl 114 | atEnd = false 115 | if (url) { 116 | var mediaUrl = url.replace('127.0.0.1', network()) 117 | var subsUrl = mediaUrl.replace(/(:\/\/.+)\/.*/, '$1/subtitles') 118 | var subsList = [] 119 | for (var i = 0; i < 100; i++) subsList.push(subsUrl) 120 | chromecast.play(mediaUrl, {title: 'Playback', seek: time || 0, subtitles: subsList, autoSubtitles: !!subs }, onmetadata) 121 | } else { 122 | chromecast.resume() 123 | } 124 | } else { 125 | if (atEnd && url === lastUrl) that.time(0) 126 | if (!url) { 127 | $video.play() 128 | } else { 129 | lastUrl = url 130 | atEnd = false 131 | $video.innerHTML = '' // clear 132 | var $src = document.createElement('source') 133 | $src.setAttribute('src', url) 134 | $src.setAttribute('type', 'video/mp4') 135 | $video.appendChild($src) 136 | if (changed) $video.load() 137 | $video.play() 138 | if (time) $video.currentTime = time 139 | } 140 | } 141 | that.playing = true 142 | that.emit('play') 143 | } 144 | 145 | that.pause = function () { 146 | if (chromecast) chromecast.pause() 147 | else $video.pause() 148 | that.playing = false 149 | that.emit('pause') 150 | } 151 | 152 | var subs = null 153 | that.subtitles = function (buf) { 154 | if (!arguments.length) return subs 155 | subs = buf 156 | 157 | if (chromecast) { 158 | if (!buf) chromecast.subtitles(false) 159 | else chromecast.subtitles(++chromecastSubtitles) 160 | return 161 | } 162 | 163 | if ($video.querySelector('track')) $video.removeChild($video.querySelector('track')) 164 | if (!buf) return null 165 | var $track = document.createElement('track') 166 | $track.setAttribute('default', 'default') 167 | $track.setAttribute('src', 'data:text/vtt;base64,'+buf.toString('base64')) 168 | $track.setAttribute('label', 'Subtitles') 169 | $track.setAttribute('kind', 'subtitles') 170 | $video.appendChild($track) 171 | that.emit('subtitles', buf) 172 | return buf 173 | } 174 | 175 | that.volume = function (value) { 176 | $video.volume = value 177 | } 178 | 179 | that.playbackRate = function (value) { 180 | $video.playbackRate = value 181 | } 182 | 183 | return that 184 | } 185 | -------------------------------------------------------------------------------- /playlist.js: -------------------------------------------------------------------------------- 1 | var torrents = require('webtorrent') 2 | var request = require('request') 3 | var duplex = require('duplexify') 4 | var ytdl = require('ytdl-core') 5 | var events = require('events') 6 | var path = require('path') 7 | var fs = require('fs') 8 | var vtt = require('srt-to-vtt') 9 | var concat = require('concat-stream') 10 | 11 | var noop = function () {} 12 | 13 | module.exports = function () { 14 | var that = new events.EventEmitter() 15 | 16 | that.entries = [] 17 | 18 | var onmagnet = function (link, cb) { 19 | console.log('torrent ' + link) 20 | 21 | var engine = torrents() 22 | var subtitles = {} 23 | 24 | engine.add(link, { 25 | announce: [ 'wss://tracker.webtorrent.io' ] 26 | }, function (torrent) { 27 | console.log('torrent ready') 28 | 29 | torrent.files.forEach(function (f) { 30 | if (/\.(vtt|srt)$/i.test(f.name)) { 31 | subtitles[f.name] = f; 32 | } 33 | }) 34 | 35 | torrent.files.forEach(function (f) { 36 | f.downloadSpeed = torrent.downloadSpeed() 37 | if (/\.(mp4|mkv|mp3)$/i.test(f.name)) { 38 | f.select() 39 | f.id = that.entries.push(f) - 1 40 | 41 | var basename = f.name.substr(0, f.name.lastIndexOf('.')) 42 | var subtitle = subtitles[basename + '.srt'] || subtitles[basename + '.vtt'] 43 | if (subtitle) { 44 | subtitle.createReadStream().pipe(vtt()).pipe(concat(function(data) { 45 | f.subtitles = data 46 | })) 47 | } 48 | } 49 | 50 | }) 51 | 52 | setInterval(function () { 53 | console.log(torrent.downloadSpeed() + ' (' + torrent.swarm.wires.length + ')') 54 | }, 1000) 55 | 56 | that.emit('update') 57 | cb() 58 | }) 59 | } 60 | 61 | var ontorrent = function (link, cb) { 62 | fs.readFile(link, function (err, buf) { 63 | if (err) return cb(err) 64 | onmagnet(buf, cb) 65 | }) 66 | } 67 | 68 | var onyoutube = function (link, cb) { 69 | var file = {} 70 | var url = /https?:/.test(link) ? link : 'https:' + link 71 | 72 | getYoutubeData(function (err, data) { 73 | if (err) return cb(err) 74 | var fmt = data.fmt 75 | var info = data.info 76 | request({method: 'HEAD', url: fmt.url}, function (err, resp, body) { 77 | if (err) return cb(err) 78 | var len = resp.headers['content-length'] 79 | if (!len) return cb(new Error('no content-length on response')) 80 | file.length = +len 81 | file.name = info.title 82 | 83 | file.createReadStream = function (opts) { 84 | if (!opts) opts = {} 85 | // fetch this for every range request 86 | // TODO try and avoid doing this call twice the first time 87 | getYoutubeData(function (err, data) { 88 | if (err) return cb(err) 89 | var vidUrl = data.fmt.url 90 | if (opts.start || opts.end) vidUrl += '&range=' + ([opts.start || 0, opts.end || len].join('-')) 91 | stream.setReadable(request(vidUrl)) 92 | }) 93 | 94 | var stream = duplex() 95 | return stream 96 | } 97 | file.id = that.entries.push(file) - 1 98 | that.emit('update') 99 | cb() 100 | }) 101 | }) 102 | 103 | function getYoutubeData (cb) { 104 | ytdl.getInfo(url, function (err, info) { 105 | if (err) return cb(err) 106 | 107 | var vidFmt 108 | var formats = info.formats 109 | 110 | formats.sort(function sort (a, b) { 111 | return +a.itag - +b.itag 112 | }) 113 | 114 | var vidFmt 115 | formats.forEach(function (fmt) { 116 | // prefer webm 117 | if (fmt.itag === '46') return vidFmt = fmt 118 | if (fmt.itag === '45') return vidFmt = fmt 119 | if (fmt.itag === '44') return vidFmt = fmt 120 | if (fmt.itag === '43') return vidFmt = fmt 121 | 122 | // otherwise h264 123 | if (fmt.itag === '38') return vidFmt = fmt 124 | if (fmt.itag === '37') return vidFmt = fmt 125 | if (fmt.itag === '22') return vidFmt = fmt 126 | if (fmt.itag === '18') return vidFmt = fmt 127 | }) 128 | 129 | if (!vidFmt) return cb (new Error('No suitable video format found')) 130 | 131 | cb(null, {info: info, fmt: vidFmt}) 132 | }) 133 | } 134 | } 135 | 136 | var onfile = function (link, cb) { 137 | var file = {} 138 | 139 | fs.stat(link, function (err, st) { 140 | if (err) return cb(err) 141 | 142 | file.length = st.size 143 | file.name = path.basename(link) 144 | file.createReadStream = function (opts) { 145 | return fs.createReadStream(link, opts) 146 | } 147 | 148 | file.id = that.entries.push(file) - 1 149 | 150 | var ondone = function () { 151 | that.emit('update') 152 | cb() 153 | } 154 | var basename = link.substr(0, link.lastIndexOf('.')) 155 | var extensions = ['srt', 'vtt'] 156 | var next = function () { 157 | var ext = extensions.shift() 158 | if (!ext) return ondone() 159 | 160 | fs.exists(basename + '.' + ext, function(exists) { 161 | if (!exists) return next() 162 | fs.createReadStream(basename + '.' + ext).pipe(vtt()).pipe(concat(function(data) { 163 | file.subtitles = data 164 | ondone() 165 | })) 166 | }) 167 | } 168 | next() 169 | }) 170 | } 171 | 172 | var onhttplink = function (link, cb) { 173 | var file = {} 174 | 175 | file.name = link.lastIndexOf('/') > -1 ? link.split('/').pop() : link 176 | 177 | file.createReadStream = function (opts) { 178 | if (!opts) opts = {} 179 | 180 | if (opts && (opts.start || opts.end)) { 181 | var rs = 'bytes=' + (opts.start || 0) + '-' + (opts.end || file.length || '') 182 | return request(link, {headers: {Range: rs}}) 183 | } 184 | 185 | return request(link) 186 | } 187 | 188 | // first, get the head for the content length. 189 | // IMPORTANT: servers without HEAD will not work. 190 | request.head(link, function (err, response) { 191 | if (err) return cb(err) 192 | if (!/2\d\d/.test(response.statusCode)) return cb(new Error('request failed')) 193 | 194 | file.length = Number(response.headers['content-length']) 195 | file.id = that.entries.push(file) - 1 196 | that.emit('update') 197 | cb() 198 | }) 199 | } 200 | 201 | var onipfslink = function (link, cb) { 202 | if (link[0] != '/') link = "/" + link // / may be stripped in add 203 | 204 | var local = 'localhost:8080' // todo: make this configurable 205 | var gateway = 'gateway.ipfs.io' 206 | var file = {} 207 | 208 | // first, try the local http gateway 209 | var u = 'http://' + local + link 210 | console.log('trying local ipfs gateway: ' + u) 211 | onhttplink(u, function (err) { 212 | if (!err) return cb() // done. 213 | 214 | // error? ok try fuse... maybe the gateway's broken. 215 | console.log('trying mounted ipfs fs (just in case)') 216 | onfile(link, function (err) { 217 | if (!err) return cb() // done. 218 | 219 | // worst case, try global ipfs gateway. 220 | var u = 'http://' + gateway + link 221 | console.log('trying local ipfs gateway: ' + u) 222 | onhttplink(u, cb) 223 | }) 224 | }) 225 | } 226 | 227 | that.selected = null 228 | 229 | that.deselect = function () { 230 | that.selected = null 231 | that.emit('deselect') 232 | } 233 | 234 | that.selectNext = function (loop) { 235 | if (!that.entries.length) return null 236 | if (!that.selected) return that.select(0) 237 | if (that.repeatingOne && !loop) return that.select(that.selected.id) 238 | if (that.selected.id === that.entries.length - 1) { 239 | if (that.repeating || loop) return that.select(0) 240 | else return null 241 | } 242 | return that.select(that.selected.id + 1) 243 | } 244 | 245 | that.selectPrevious = function (loop) { 246 | if (!that.entries.length) return null 247 | if (!that.selected) return that.select(that.entries.length - 1) 248 | if (that.selected.id === 0) { 249 | if (that.repeating || loop) return that.select(that.entries.length - 1) 250 | else return null 251 | } 252 | return that.select(that.selected.id - 1) 253 | } 254 | 255 | that.select = function (id) { 256 | that.selected = that.get(id) 257 | that.emit('select') 258 | return that.selected 259 | } 260 | 261 | that.get = function (id) { 262 | return that.entries[id] 263 | } 264 | 265 | that.add = function (link, cb) { 266 | link = link.replace('playback://', '').replace('playback:', '') // strip playback protocol 267 | if (!cb) cb = noop 268 | if (/magnet:/.test(link)) return onmagnet(link, cb) 269 | if (/\.torrent$/i.test(link)) return ontorrent(link, cb) 270 | if (/youtube\.com\/watch|youtu.be/i.test(link)) return onyoutube(link, cb) 271 | if (/^\/*(ipfs|ipns)\//i.test(link)) return onipfslink(link, cb) 272 | if (/^https?:\/\//i.test(link)) return onhttplink(link, cb) 273 | onfile(link, cb) 274 | } 275 | 276 | that.repeating = false 277 | that.repeatingOne = false 278 | 279 | that.repeat = function () { 280 | that.repeating = true 281 | that.repeatingOne = false 282 | } 283 | 284 | that.repeatOne = function () { 285 | that.repeating = true 286 | that.repeatingOne = true 287 | } 288 | 289 | that.unrepeat = function () { 290 | that.repeating = false 291 | that.repeatingOne = false 292 | } 293 | 294 | return that 295 | } 296 | -------------------------------------------------------------------------------- /splash.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mafintosh/playback/edf30bd3b99a13880f648de8a8553fdeec225ee0/splash.gif --------------------------------------------------------------------------------