├── .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 | ' +
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
--------------------------------------------------------------------------------