Show Blender Foundation videos by default

* Finishes the saved torrents feature!
* Torrents load and save correctly. Poster is autogenerated only once.
* Torrents can be paused and restarted
* Download button indicates state:
 - White means paused
 - Pulsating green means downloading
 - Solid green means finished downloading, only seeding
This commit is contained in:
DC
2016-03-07 01:34:44 -08:00
parent 904d3a6c4b
commit 4b890ee9f6
10 changed files with 247 additions and 137 deletions

View File

@@ -64,7 +64,7 @@ function setAspectRatio (aspectRatio, extraSize) {
// Display string in dock badging area (OS X)
function setBadge (text) {
debug('setBadge %s', text)
electron.app.dock.setBadge(String(text))
if (electron.app.dock) electron.app.dock.setBadge(String(text))
}
// Show progress bar. Valid range is [0, 1]. Remove when < 0; indeterminate when > 1.

View File

@@ -25,7 +25,7 @@ function createMainWindow (menu) {
title: config.APP_NAME,
titleBarStyle: 'hidden-inset', // Hide OS chrome, except traffic light buttons (OS X)
width: 450,
height: 300
height: 450
})
win.loadURL(config.INDEX)

View File

@@ -13,6 +13,7 @@
"dependencies": {
"airplay-js": "guerrerocarlos/node-airplay-js",
"application-config": "^0.2.0",
"application-config-path": "^0.1.0",
"chromecasts": "^1.8.0",
"create-torrent": "^3.22.1",
"debug": "^2.2.0",
@@ -24,8 +25,7 @@
"pretty-bytes": "^3.0.0",
"upload-element": "^1.0.1",
"virtual-dom": "^2.1.1",
"webtorrent": "^0.82.1",
"xtend": "^4.0.1"
"webtorrent": "^0.82.1"
},
"devDependencies": {
"electron-packager": "^5.0.0",

View File

@@ -288,6 +288,7 @@ body.drag::before {
position: absolute;
top: 20px;
left: 20px;
width: calc(100% - 100px);
text-shadow: rgba(0, 0, 0, 0.5) 0 0 4px;
}
@@ -317,6 +318,22 @@ body.drag::before {
padding-top: 6px;
}
.torrent .buttons .download.downloading {
animation-name: greenpulse;
animation-duration: 0.8s;
animation-direction: alternate;
animation-iteration-count: infinite;
}
@keyframes greenpulse {
0% { color: #ffffff }
100% { color: #44dd44 }
}
.torrent .buttons .download.seeding {
color: #44dd44;
}
.torrent .buttons .play {
padding-top: 10px;
background-color: #F44336;

View File

@@ -1,18 +1,16 @@
/* global URL, Blob */
console.time('init')
var airplay = require('airplay-js')
var cfg = require('application-config')('WebTorrent')
var cfgDirectory = require('application-config-path')('WebTorrent')
var chromecasts = require('chromecasts')()
var config = require('../config')
var createTorrent = require('create-torrent')
var dragDrop = require('drag-drop')
var electron = require('electron')
var EventEmitter = require('events')
var extend = require('xtend')
var fs = require('fs')
var mainLoop = require('main-loop')
var networkAddress = require('network-address')
var os = require('os')
var path = require('path')
var torrentPoster = require('./lib/torrent-poster')
var WebTorrent = require('webtorrent')
@@ -23,8 +21,6 @@ var patch = require('virtual-dom/patch')
var App = require('./views/app')
var HOME = os.homedir()
// For easy debugging in Developer Tools
var state = global.state = require('./state')
@@ -38,10 +34,6 @@ global.WEBTORRENT_ANNOUNCE = createTorrent.announceList
})
var vdomLoop
var defaultSaved = {
torrents: [],
downloadPath: path.join(HOME, 'Downloads')
}
// All state lives in state.js. `state.saved` is read from and written to a file.
// All other state is ephemeral. First we load state.saved then initialize the app.
@@ -53,14 +45,12 @@ loadState(init)
* the dock icon and drag+drop.
*/
function init () {
document.querySelector('.loading').remove()
// Connect to the WebTorrent and BitTorrent networks
// WebTorrent.app is a hybrid client, as explained here: https://webtorrent.io/faq
state.client = new WebTorrent()
state.client.on('warning', onWarning)
state.client.on('error', onError)
state.client.on('torrent', saveTorrentData)
resumeTorrents() /* restart everything we were torrenting last time the app ran */
// The UI is built with virtual-dom, a minimalist library extracted from React
// The concepts--one way data flow, a pure function that renders state to a
@@ -78,8 +68,8 @@ function init () {
// (eg % downloaded) and to keep the cursor in sync when playing a video
setInterval(update, 1000)
// Resume all saved torrents now that state is loaded and vdom is ready
resumeAllTorrents()
// All state lives in state.js. `state.saved` is read from and written to a
// file. All other state is ephemeral. Here we'll load state.saved:
window.addEventListener('beforeunload', saveState)
// listen for messages from the main process
@@ -121,6 +111,8 @@ function init () {
state.isFocused = false
})
// Done! Ideally we want to get here <100ms after the user clicks the app
document.querySelector('.loading').remove() /* TODO: no spinner once fast enough */
console.timeEnd('init')
}
@@ -138,24 +130,29 @@ function update () {
// Events from the UI never modify state directly. Instead they call dispatch()
function dispatch (action, ...args) {
console.log('dispatch: %s %o', action, args)
if (['videoMouseMoved', 'playbackJump'].indexOf(action) < 0) {
console.log('dispatch: %s %o', action, args) /* log user interactions, but don't spam */
}
if (action === 'addTorrent') {
addTorrent(args[0] /* torrentId */)
addTorrent(args[0] /* torrent */)
}
if (action === 'seed') {
seed(args[0] /* files */)
}
if (action === 'openPlayer') {
openPlayer(args[0] /* torrent */)
openPlayer(args[0] /* infoHash */)
}
if (action === 'toggleTorrent') {
toggleTorrent(args[0] /* infoHash */)
}
if (action === 'deleteTorrent') {
deleteTorrent(args[0] /* torrent */)
deleteTorrent(args[0] /* infoHash */)
}
if (action === 'openChromecast') {
openChromecast(args[0] /* torrent */)
openChromecast(args[0] /* infoHash */)
}
if (action === 'openAirplay') {
openAirplay(args[0] /* torrent */)
openAirplay(args[0] /* infoHash */)
}
if (action === 'setDimensions') {
setDimensions(args[0] /* dimensions */)
@@ -226,14 +223,17 @@ function loadState (callback) {
electron.ipcRenderer.send('log', 'loaded state from ' + cfg.filePath)
// populate defaults if they're not there
state.saved = extend(defaultSaved, data)
state.saved = Object.assign({}, state.defaultSavedState, data)
if (callback) callback()
})
}
function resumeAllTorrents () {
state.saved.torrents.forEach((x) => startTorrenting(x.infoHash))
// Starts all torrents that aren't paused on program startup
function resumeTorrents () {
state.saved.torrents
.filter((x) => x.status !== 'paused')
.forEach((x) => startTorrenting(x.infoHash))
}
// Write state.saved to the JSON state file
@@ -279,57 +279,60 @@ function isNotTorrentFile (file) {
return !isTorrentFile(file)
}
// Gets a torrent summary {name, infoHash, status} from state.saved.torrents
// Returns undefined if we don't know that infoHash
function getTorrentSummary (infoHash) {
return state.saved.torrents.find((x) => x.infoHash === infoHash)
}
// Get an active torrent from state.client.torrents
// Returns undefined if we are not currently torrenting that infoHash
function getTorrent (infoHash) {
return state.client.torrents.find((x) => x.infoHash === infoHash)
}
// Adds a torrent to the list, starts downloading/seeding. TorrentID can be a
// magnet URI, infohash, or torrent file: https://github.com/feross/webtorrent#clientaddtorrentid-opts-function-ontorrent-torrent-
function addTorrent (torrentId) {
if (!torrentId) torrentId = 'magnet:?xt=urn:btih:6a9759bffd5c0af65319979fb7832189f4f3c35d&dn=sintel.mp4'
// Charlie Chaplin: 'magnet:?xt=urn:btih:cddf0459a718523480f7499da5ed1a504cffecb8&dn=charlie%5Fchaplin%5Ffilm%5Ffestival'
if (!torrentId) torrentId = 'magnet:?xt=urn:btih:6a02592d2bbc069628cd5ed8a54f88ee06ac0ba5&dn=CosmosLaundromatFirstCycle'
var torrent = startTorrenting(torrentId)
// check if torrent is duplicate
var exists = state.saved.torrents.find((x) => x.infoHash === torrent.infoHash)
if (exists) return window.alert('That torrent is already downloading.')
// If torrentId is a torrent file, wait for WebTorrent to finish reading it
if (!torrent.infoHash) torrent.on('infoHash', addTorrentToList)
else addTorrentToList()
// save only if infoHash is available
if (torrent.infoHash) {
function addTorrentToList () {
if (getTorrentSummary(torrent.infoHash)) {
return // Skip, torrent is already in state.saved
}
state.saved.torrents.push({
status: 'new',
name: torrent.name,
magnetURI: torrent.magnetURI,
infoHash: torrent.infoHash
})
} else {
torrent.on('infoHash', () => saveTorrentData(torrent))
saveState()
}
saveState()
}
// add torrent metadata to state once it's available
function saveTorrentData (torrent) {
var ix = state.saved.torrents.findIndex((x) => x.infoHash === torrent.infoHash)
var data = {
name: torrent.name,
magnetURI: torrent.magnetURI,
infoHash: torrent.infoHash,
path: torrent.path,
xt: torrent.xt,
dn: torrent.dn,
announce: torrent.announce
}
if (ix === -1) state.saved.torrents.push(data)
else state.saved.torrents[ix] = data
saveState()
}
// Starts downloading and/or seeding a given torrent file or magnet URI
function startTorrenting (torrentId) {
var torrent = state.client.add(torrentId, {
// use downloads folder
// Starts downloading and/or seeding a given torrent, torrentSummary or magnet URI
function startTorrenting (infoHash) {
var torrent = state.client.add(infoHash, {
// Use downloads folder
path: state.saved.downloadPath
})
addTorrentEvents(torrent)
return torrent
}
// Stops downloading and/or seeding. See startTorrenting
function stopTorrenting (infoHash) {
var torrent = getTorrent(infoHash)
if (torrent) torrent.destroy()
}
// Creates a torrent for a local file and starts seeding it
function seed (files) {
if (files.length === 0) return
@@ -339,33 +342,60 @@ function seed (files) {
function addTorrentEvents (torrent) {
torrent.on('infoHash', update)
torrent.on('ready', torrentReady)
torrent.on('done', torrentDone)
update()
function torrentReady () {
torrentPoster(torrent, function (err, buf) {
if (err) return onWarning(err)
torrent.posterURL = URL.createObjectURL(new Blob([ buf ], { type: 'image/png' }))
update()
})
var torrentSummary = getTorrentSummary(torrent.infoHash)
torrentSummary.status = 'downloading'
torrentSummary.ready = true
torrentSummary.name = torrent.name
torrentSummary.infoHash = torrent.infoHash
if (!torrentSummary.posterURL) {
generateTorrentPoster(torrent, torrentSummary)
}
update()
}
function torrentDone () {
var torrentSummary = getTorrentSummary(torrent.infoHash)
torrentSummary.status = 'seeding'
if (!state.isFocused) {
state.dock.badge += 1
electron.ipcRenderer.send('setBadge', state.dock.badge)
}
update()
}
}
function startServer (torrent, cb) {
function generateTorrentPoster (torrent, torrentSummary) {
torrentPoster(torrent, function (err, buf) {
if (err) return onWarning(err)
// save it for next time
var posterFilePath = path.join(cfgDirectory, torrent.infoHash + '.jpg')
fs.writeFile(posterFilePath, buf, function (err) {
if (err) return onWarning(err)
// show the poster
torrentSummary.posterURL = 'file:///' + posterFilePath
update()
})
})
}
function startServer (infoHash, cb) {
if (state.server) return cb()
var torrent = getTorrent(infoHash)
if (!torrent) torrent = startTorrenting(infoHash)
if (torrent.ready) startServerFromReadyTorrent(torrent, cb)
else torrent.on('ready', () => startServerFromReadyTorrent(torrent, cb))
}
function startServerFromReadyTorrent (torrent, cb) {
// use largest file
state.torrentPlaying = torrent.files.reduce(function (a, b) {
return a.length > b.length ? a : b
@@ -385,15 +415,15 @@ function startServer (torrent, cb) {
})
}
function closeServer () {
function stopServer () {
state.server.server.destroy()
state.server = null
}
function openPlayer (torrent) {
startServer(torrent, function () {
function openPlayer (infoHash) {
startServer(infoHash, function () {
state.url = '/player'
state.title = torrent.name
/* TODO: set state.title to the clean name of the torrent */
update()
})
}
@@ -405,20 +435,36 @@ function closePlayer () {
electron.ipcRenderer.send('toggleFullScreen')
}
restoreBounds()
closeServer()
stopServer()
update()
}
function deleteTorrent (torrent) {
var ix = state.saved.torrents.findIndex((x) => x.infoHash === torrent.infoHash)
if (ix > -1) state.saved.torrents.splice(ix, 1)
torrent.destroy(saveState)
function toggleTorrent (infoHash) {
var torrentSummary = getTorrentSummary(infoHash)
if (!torrentSummary) return
if (torrentSummary.status === 'paused') {
torrentSummary.status = 'new'
startTorrenting(torrentSummary.infoHash)
} else {
torrentSummary.status = 'paused'
stopTorrenting(torrentSummary.infoHash)
}
}
function openChromecast (torrent) {
startServer(torrent, function () {
function deleteTorrent (infoHash) {
var torrent = getTorrent(infoHash)
torrent.destroy()
var index = state.saved.torrents.findIndex((x) => x.infoHash === infoHash)
if (index > -1) state.saved.torrents.splice(index, 1)
saveState()
}
function openChromecast (infoHash) {
var torrentSummary = getTorrentSummary(infoHash)
startServer(infoHash, function () {
state.devices.chromecast.play(state.server.networkURL, {
title: config.APP_NAME + ' — ' + torrent.name
title: config.APP_NAME + ' — ' + torrentSummary.name
})
state.devices.chromecast.on('error', function (err) {
err.message = 'Chromecast: ' + err.message
@@ -428,8 +474,8 @@ function openChromecast (torrent) {
})
}
function openAirplay (torrent) {
startServer(torrent, function () {
function openAirplay (infoHash) {
startServer(infoHash, function () {
state.devices.airplay.play(state.server.networkURL, 0, function () {
// TODO: handle airplay errors
})

View File

@@ -1,3 +1,6 @@
var os = require('os')
var path = require('path')
var config = require('../config')
module.exports = {
@@ -44,5 +47,31 @@ module.exports = {
*/
saved: {
torrents: []
},
/* If the saved state file doesn't exist yet, here's what we use instead */
defaultSavedState: {
version: 1, /* make sure we can upgrade gracefully later */
torrents: [
{
status: 'paused',
infoHash: 'f84b51f0d2c3455ab5dabb6643b4340234cd036e',
displayName: 'Big Buck Bunny',
posterURL: '../resources/bigBuckBunny.jpg'
},
{
status: 'paused',
infoHash: '6a9759bffd5c0af65319979fb7832189f4f3c35d',
displayName: 'Sintel',
posterURL: '../resources/sintel.jpg'
},
{
status: 'paused',
infoHash: '02767050e0be2fd4db9a2ad6c12416ac806ed6ed',
displayName: 'Tears of Steel',
posterURL: '../resources/tearsOfSteel.jpg'
}
],
downloadPath: path.join(os.homedir(), 'Downloads')
}
}

View File

@@ -7,7 +7,8 @@ var hx = hyperx(h)
var prettyBytes = require('pretty-bytes')
function TorrentList (state, dispatch) {
var list = state.client.torrents.map((torrent) => renderTorrent(torrent, dispatch))
var list = state.saved.torrents.map(
(torrentSummary) => renderTorrent(torrentSummary, state, dispatch))
if (list.length === 0) list = emptyList()
return hx`<div class='torrent-list'>${list}</div>`
}
@@ -24,76 +25,93 @@ function emptyList () {
// Renders a torrent in the torrent list
// Includes name, download status, play button, background image
// May be expanded for additional info, including the list of files inside
function renderTorrent (torrent, dispatch) {
function renderTorrent (torrentSummary, state, dispatch) {
// Get ephemeral data (like progress %) directly from the WebTorrent handle
var torrent = state.client.torrents.find((x) => x.infoHash === torrentSummary.infoHash)
// Background image: show some nice visuals, like a frame from the movie, if possible
var style = {}
if (torrent.posterURL) {
style['background-image'] = `linear-gradient(to bottom, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0) 100%), url("${torrent.posterURL}")`
if (torrentSummary.posterURL) {
style['background-image'] = 'linear-gradient(to bottom, ' +
'rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0) 100%), ' +
`url("${torrentSummary.posterURL}")`
}
// Foreground: name of the torrent, basic info like size, play button,
// cast buttons if available, and delete
return hx`
<div class='torrent' style=${style}>
${renderTorrentMetadata(torrent)}
${renderTorrentButtons(torrent)}
${renderTorrentMetadata(torrent, torrentSummary)}
${renderTorrentButtons(torrentSummary, dispatch)}
</div>
`
}
function renderTorrentMetadata () {
// Show name, download status, % complete
function renderTorrentMetadata (torrent, torrentSummary) {
var name = torrentSummary.displayName || torrentSummary.name || 'Loading torrent...'
var elements = [hx`
<div class='name ellipsis'>${name}</div>
`]
// If a torrent is paused and we only get the torrentSummary
// If it's downloading/seeding then we have more information
if (torrent) {
var progress = Math.floor(100 * torrent.progress)
var downloaded = prettyBytes(torrent.downloaded)
var total = prettyBytes(torrent.length || 0)
if (downloaded !== total) downloaded += ` / ${total}`
return hx`
<div class='metadata'>
<div class='name ellipsis'>${torrent.name || 'Loading torrent...'}</div>
<div class='status ellipsis'>
${getFilesLength()}
<span>${getPeers()}</span>
<span>↓ ${prettyBytes(torrent.downloadSpeed || 0)}/s</span>
<span>↑ ${prettyBytes(torrent.uploadSpeed || 0)}/s</span>
</div>
<div class='status2 ellipsis'>
<span class='progress'>${progress}%</span>
<span>${downloaded}</span>
</div>
elements.push(hx`
<div class='status ellipsis'>
${getFilesLength()}
<span>${getPeers()}</span>
<span>↓ ${prettyBytes(torrent.downloadSpeed || 0)}/s</span>
<span>${prettyBytes(torrent.uploadSpeed || 0)}/s</span>
</div>
`
function getPeers () {
var count = torrent.numPeers === 1 ? 'peer' : 'peers'
return `${torrent.numPeers} ${count}`
}
function getFilesLength () {
if (torrent.ready && torrent.files.length > 1) {
return hx`<span class='files'>${torrent.files.length} files</span>`
}
}
`)
elements.push(hx`
<div class='status2 ellipsis'>
<span class='progress'>${progress}%</span>
<span>${downloaded}</span>
</div>
`)
}
function renderTorrentButtons () {
return hx`
<div class="buttons">
<i.btn.icon.download
class='${torrent.state}'
onclick=${() => dispatch('toggleTorrent', torrent)}>
file_download
</i>
<i.btn.icon.play
class='${!torrent.ready ? 'disabled' : ''}'
onclick=${() => dispatch('openPlayer', torrent)}>
play_arrow
</i>
<i
class='icon delete'
onclick=${() => dispatch('deleteTorrent', torrent)}>
close
</i>
</div>
`
return hx`<div class='metadata'>${elements}</div>`
function getPeers () {
var count = torrent.numPeers === 1 ? 'peer' : 'peers'
return `${torrent.numPeers} ${count}`
}
function getFilesLength () {
if (torrent.ready && torrent.files.length > 1) {
return hx`<span class='files'>${torrent.files.length} files</span>`
}
}
}
// Download button toggles between torrenting (DL/seed) and paused
// Play button starts streaming the torrent immediately, unpausing if needed
function renderTorrentButtons (torrentSummary, dispatch) {
var infoHash = torrentSummary.infoHash
return hx`
<div class="buttons">
<i.btn.icon.download
class='${torrentSummary.status}'
onclick=${() => dispatch('toggleTorrent', infoHash)}>
file_download
</i>
<i.btn.icon.play
onclick=${() => dispatch('openPlayer', infoHash)}>
play_arrow
</i>
<i
class='icon delete'
onclick=${() => dispatch('deleteTorrent', infoHash)}>
close
</i>
</div>
`
}

BIN
resources/bigBuckBunny.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

BIN
resources/sintel.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
resources/tearsOfSteel.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB