diff --git a/.babelrc b/.babelrc
new file mode 100644
index 0000000..12d195e
--- /dev/null
+++ b/.babelrc
@@ -0,0 +1,3 @@
+{
+ "presets": ["react"]
+}
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..2ce6d26
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,8 @@
+root = true
+
+# Unix-style newlines with a newline ending every file
+[*]
+end_of_line = lf
+insert_final_newline = true
+indent_style = space
+indent_size = 2
diff --git a/.eslintrc b/.eslintrc
new file mode 100644
index 0000000..319fdfd
--- /dev/null
+++ b/.eslintrc
@@ -0,0 +1,15 @@
+{
+ "extends": "standard",
+ "plugins": [
+ "react"
+ ],
+ "parserOptions": {
+ "ecmaFeatures": {
+ "jsx": true
+ }
+ },
+ "rules": {
+ "react/jsx-uses-vars": 2
+ }
+
+}
diff --git a/.gitignore b/.gitignore
index 08e8372..d52e4dd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ Playback-win32.zip
Playback.app
Playback.app.zip
.DS_Store
+app/front/styles/main.css
diff --git a/README.md b/README.md
index fa3cc10..76388b8 100644
--- a/README.md
+++ b/README.md
@@ -26,8 +26,8 @@ If you think it is missing a feature or you've found a bug feel free to open an
## Development
-Simply clone this repo and run `npm install` and then `npm run rebuild`.
-Afterwards you can run `npm start` to run the app.
+Simply clone this repo and run `npm install`.
+Afterwards you can run `npm run dev` to compile, lint, then start.
## License
diff --git a/app.js b/app.js
deleted file mode 100755
index 3ef06c0..0000000
--- a/app.js
+++ /dev/null
@@ -1,97 +0,0 @@
-#!/usr/bin/env electron
-
-var app = require('app')
-var BrowserWindow = require('browser-window')
-var path = require('path')
-var ipc = require('electron').ipcMain
-var dialog = require('dialog')
-var shell = require('shell')
-var powerSaveBlocker = require('electron').powerSaveBlocker
-
-var win
-var link
-var ready = false
-
-var onopen = function (e, lnk) {
- e.preventDefault()
-
- if (ready) {
- win.send('add-to-playlist', [].concat(lnk))
- return
- }
-
- link = lnk
-}
-
-app.on('open-file', onopen)
-app.on('open-url', onopen)
-
-var frame = process.platform === 'win32'
-
-app.on('ready', function () {
- win = new BrowserWindow({
- title: 'playback',
- width: 860,
- height: 470,
- frame: frame,
- show: false,
- transparent: true
- })
-
- win.loadURL('file://' + path.join(__dirname, 'index.html#' + JSON.stringify(process.argv.slice(2))))
-
- ipc.on('close', function () {
- app.quit()
- })
-
- ipc.on('open-file-dialog', function () {
- var files = dialog.showOpenDialog({ properties: [ 'openFile', 'multiSelections' ]})
- if (files) win.send('add-to-playlist', files)
- })
-
- ipc.on('open-url-in-external', function (event, url) {
- shell.openExternal(url)
- })
-
- ipc.on('focus', function () {
- win.focus()
- })
-
- ipc.on('minimize', function () {
- win.minimize()
- })
-
- ipc.on('maximize', function () {
- win.maximize()
- })
-
- ipc.on('resize', function (e, message) {
- if (win.isMaximized()) return
- var wid = win.getSize()[0]
- var hei = (wid / message.ratio) | 0
- win.setSize(wid, hei)
- })
-
- ipc.on('enter-full-screen', function () {
- win.setFullScreen(true)
- })
-
- ipc.on('exit-full-screen', function () {
- win.setFullScreen(false)
- win.show()
- })
-
- ipc.on('ready', function () {
- ready = true
- if (link) win.send('add-to-playlist', [].concat(link))
- win.show()
- })
-
- ipc.on('prevent-sleep', function () {
- app.sleepId = powerSaveBlocker.start('prevent-display-sleep')
- })
-
- ipc.on('allow-sleep', function () {
- powerSaveBlocker.stop(app.sleepId)
- })
-})
diff --git a/app/Controller.js b/app/Controller.js
new file mode 100644
index 0000000..4a09778
--- /dev/null
+++ b/app/Controller.js
@@ -0,0 +1,461 @@
+'use strict'
+
+const EventEmitter = require('events').EventEmitter
+const update = require('react-addons-update')
+const uuid = require('node-uuid')
+const chromecasts = require('chromecasts')
+
+const ChromecastPlayer = require('./players/Chromecast')
+const Server = require('./Server')
+
+const fileLoader = require('./loaders/file')
+const youtubeLoader = require('./loaders/youtube')
+const magnetLoader = require('./loaders/magnet')
+const torrentLoader = require('./loaders/torrent')
+const httpLoader = require('./loaders/http')
+const ipfsLoader = require('./loaders/ipfs')
+
+const playerEvents = require('./players/playerEvents')
+
+const loaders = [youtubeLoader, magnetLoader, torrentLoader, httpLoader, ipfsLoader, fileLoader]
+
+function Controller (follow) {
+ this.STATUS_STOPPED = 'stopped'
+ this.STATUS_PAUSED = 'paused'
+ this.STATUS_PLAYING = 'playing'
+ this.PLAYER_HTML = 'html'
+ this.PLAYER_CHROMECAST = 'chromecast'
+
+ this.REMOTE_RECEIVE = ['togglePlay', 'start', 'remove', 'seek', 'setMuted', 'setVolume', 'toggleSubtitles', 'addAndStart', 'add', 'addSubtitles', 'updateChromecasts', 'setPlayer', 'loadFiles', 'openFileDialog', 'playerMetadata', 'playerEnd', 'playerStatus']
+ this.REMOTE_SEND = ['update'].concat(playerEvents)
+
+ // Create server
+ this.server = new Server(this, follow, (serverPath) => this.emit('ready', serverPath))
+
+ // Initial state
+ this.setState({
+ status: this.STATUS_STOPPED,
+ volume: 1,
+ muted: false,
+ subtitles: false,
+ casting: null,
+ currentFile: null,
+ currentTime: 0,
+ duration: 0,
+ videoWidth: 0,
+ videoHeight: 0,
+ playlist: [],
+ chromecasts: [],
+ player: null
+ })
+
+ // Setup chromecasts and update listener
+ this.chromecasts = chromecasts()
+ this.chromecasts.on('update', () => {
+ this.setState({
+ chromecasts: this.chromecasts.players.map((d) => {
+ return {
+ host: d.host,
+ name: d.name,
+ id: d.host + d.name
+ }
+ })
+ })
+ })
+
+ this.fileStreams = []
+ this._chromecastPlayer = new ChromecastPlayer(this, this.chromecasts)
+}
+
+Object.assign(Controller.prototype, EventEmitter.prototype, {
+
+ /*
+ * Refresh chromecasts list
+ */
+
+ updateChromecasts () {
+ this.chromecasts.update()
+ },
+
+ /*
+ * Update state
+ */
+
+ setState (state) {
+ this.state = Object.assign({}, this.state || {}, state)
+ this.emit('update', this.state)
+ },
+
+ /*
+ * Get state
+ */
+
+ getState () {
+ return this.state
+ },
+
+ /*
+ * Load files (media or subtitles)
+ */
+
+ loadFiles (files) {
+ const subtitles = files.some((f) => {
+ if (f.match(/\.(srt|vtt)$/i)) {
+ this.addSubtitles(f)
+ return true
+ }
+ })
+
+ if (!subtitles) {
+ const autoPlay = !this.state.playlist.length
+ if (autoPlay) {
+ this.addAndStart(files)
+ } else {
+ this.add(files)
+ }
+ }
+ },
+
+ /*
+ * Add subtitles to currentFile
+ */
+
+ addSubtitles (path) {
+ if (this.state.currentFile) {
+ this.setState({ loading: true })
+ fileLoader.loadSubtitle(path).then((data) => {
+ this.getFile(this.state.currentFile.id).subtitles = data
+ this.state.currentFile.subtitles = true
+ if (this.state.showSubtitles) {
+ this.emit('showSubtitles')
+ }
+ this.setState({ loading: false })
+ })
+ }
+ },
+
+ /*
+ * Add URI(s) to the playlist. This loads them and returns a promise that resolves when all files are loaded
+ */
+
+ add (uris) {
+ this.setState({ loading: true })
+
+ let list = uris
+ if (!uris.slice) list = [uris]
+
+ const proms = []
+ list.forEach((uri) => {
+ loaders.some((loader) => {
+ if (loader.test(uri)) {
+ proms.push(loader.load(uri).then((file) => {
+ file.id = uuid.v4()
+ file.streamUrl = this.server.getPath() + '/' + file.id
+ file.subtitlesUrl = file.streamUrl + '/subtitles'
+ this.fileStreams.push(file)
+ this.setState(update(this.state, {
+ playlist: {
+ $push: [{
+ id: file.id,
+ streamUrl: file.streamUrl,
+ subtitlesUrl: file.subtitlesUrl,
+ name: file.name
+ }]
+ }
+ }))
+ return file
+ }))
+ return true
+ }
+ })
+ })
+
+ return Promise.all(proms).then((files) => {
+ this.setState({ loading: false })
+ return files
+ }).catch((e) => {
+ this.setState({ loading: false })
+ console.error(e)
+ })
+ },
+
+ /*
+ * Add URI(s) to the playlist and start
+ */
+
+ addAndStart (uris) {
+ return this.add(uris).then((files) => {
+ this.start(files[0], true)
+ return files
+ })
+ },
+
+ /*
+ * Play a file
+ */
+
+ start (file, autoPlay, currentTime, showSubtitles) {
+ currentTime = currentTime || 0
+ showSubtitles = showSubtitles || false
+
+ if (this.state.status !== this.STATUS_STOPPED) { this.stop() }
+
+ this.setState({
+ status: autoPlay ? this.STATUS_PLAYING : this.STATUS_PAUSED,
+ currentFile: Object.assign({}, file),
+ duration: 0,
+ currentTime
+ })
+
+ if (autoPlay) {
+ this.emit('preventSleep')
+ }
+
+ this.emit('start', file, autoPlay, currentTime, showSubtitles, this.state.volume, this.state.muted)
+ },
+
+ /*
+ * Resume playing current file
+ */
+
+ resume () {
+ this.setState({ status: this.STATUS_PLAYING })
+ this.emit('preventSleep')
+ this.emit('resume')
+ },
+
+ /*
+ * Pause playback
+ */
+
+ pause () {
+ this.setState({ status: this.STATUS_PAUSED })
+ this.emit('allowSleep')
+ this.emit('pause')
+ },
+
+ /*
+ * Stop playback
+ */
+
+ stop () {
+ this.setState({
+ status: this.STATUS_STOPPED,
+ currentTime: 0,
+ duration: 0,
+ currentFile: null,
+ buffering: false,
+ buffered: null,
+ videoWidth: 0,
+ videoHeight: 0
+ })
+ this.emit('allowSleep')
+ this.emit('stop')
+ },
+
+ /*
+ * Toggle playing
+ */
+
+ togglePlay () {
+ if (this.state.status === this.STATUS_PAUSED) {
+ this.resume()
+ } else if (this.state.status === this.STATUS_PLAYING) {
+ this.pause()
+ }
+ },
+
+ /*
+ * Toggle showing subtitles
+ */
+
+ toggleSubtitles () {
+ const show = !this.state.showSubtitles
+ this.setState({ showSubtitles: show })
+ if (show) {
+ this.emit('showSubtitles', this.state.currentFile.subtitlesUrl)
+ } else {
+ this.emit('hideSubtitles')
+ }
+ },
+
+ /*
+ * Seek to a particular second
+ */
+
+ seek (second) {
+ this.setState({ currentTime: second })
+ this.emit('seek', second)
+ },
+
+ /*
+ * Handle when the player emits a status. Set currentTime and buffered list
+ */
+
+ playerStatus (status) {
+ this.setState({
+ currentTime: status.currentTime || this.state.currentTime,
+ buffering: status.buffering,
+ buffered: status.buffered
+ })
+ },
+
+ /*
+ * Handle when the player emits metadata. Set the video duration, width, and height if available.
+ */
+
+ playerMetadata (metadata) {
+ this.setState({
+ duration: metadata.duration,
+ videoWidth: metadata.width,
+ videoHeight: metadata.height
+ })
+ },
+
+ /*
+ * Handle when the player has ended the current file. Start next in the playlist.
+ */
+
+ playerEnd () {
+ this.next()
+ },
+
+ /*
+ * Return the next item in the playlist, if it exists
+ */
+
+ getNext () {
+ const currentFile = this.state.currentFile
+ const playlist = this.state.playlist
+ if (!currentFile) return
+ const currentIndex = playlist.findIndex((f) => currentFile.id === f.id)
+ if (currentIndex > -1) {
+ const nextFile = playlist[currentIndex + 1]
+ return nextFile
+ }
+ },
+
+ /*
+ * Return the previous item in the playlist, if it exists
+ */
+
+ getPrevious () {
+ const currentFile = this.state.currentFile
+ const playlist = this.state.playlist
+ if (!currentFile) return
+ const currentIndex = playlist.findIndex((f) => currentFile.id === f.id)
+ if (currentIndex > -1) {
+ const prevFile = playlist[currentIndex - 1]
+ return prevFile
+ }
+ },
+
+ /*
+ * Load the next item in the playlist. Autoplay if we're already playing
+ */
+
+ next () {
+ const nextFile = this.getNext()
+ if (!nextFile) return this.stop()
+ this.start(nextFile, this.state.status === this.STATUS_PLAYING)
+ },
+
+ /*
+ * Go to the previous track, or the beginning of the current track if the currentTime is > 10
+ */
+
+ previous () {
+ const prevFile = this.getPrevious()
+ if (this.state.currentTime > 10) {
+ this.seek(0)
+ } else if (!prevFile) {
+ this.stop()
+ } else {
+ this.start(prevFile, this.state.status === this.STATUS_PLAYING)
+ }
+ },
+
+ /*
+ * Remove an item from the playlist by index. If it's the currently playing file, go next()
+ */
+
+ remove (index) {
+ const file = this.state.playlist[index]
+ if (file) {
+ if (this.state.currentFile && file.id === this.state.currentFile.id) {
+ this.next()
+ }
+ this.fileStreams.splice(index, 1)
+ this.setState(update(this.state, { playlist: { $splice: [[index, 1]] } }))
+ }
+ },
+
+ /*
+ * Set the player to use.
+ *
+ * Disables the current player
+ * Enables the new player
+ * Continue where we left off
+ */
+
+ setPlayer (type, playerOpts) {
+ const currentFile = this.state.currentFile
+ const currentTime = this.state.currentTime
+
+ const autoPlay = this.state.status === this.STATUS_PLAYING
+
+ if (this.state.status !== this.STATUS_STOPPED) {
+ this.stop()
+ }
+
+ this.emit('disablePlayer')
+
+ if (type === this.PLAYER_CHROMECAST) {
+ this.setState({ player: type, casting: playerOpts.deviceId, buffering: false, buffered: null })
+ this.emit('enablePlayer', playerOpts.deviceId)
+ } else if (type === this.PLAYER_HTML) {
+ this.setState({ player: type, casting: null, buffering: false, buffered: null })
+ this.emit('enablePlayer')
+ }
+
+ if (currentFile) {
+ this.start(currentFile, autoPlay, currentTime)
+ }
+ },
+
+ /*
+ * Set the volume (0-1)
+ */
+
+ setVolume (volume) {
+ this.setState({ volume })
+ this.emit('setVolume', volume)
+ },
+
+ /*
+ * Set muted
+ */
+
+ setMuted (muted) {
+ this.setState({ muted })
+ this.emit('setMuted', muted)
+ },
+
+ /*
+ * Get a file stream by id
+ */
+
+ getFile (id) {
+ return this.fileStreams[this.fileStreams.findIndex((f) => f.id === id)]
+ },
+
+ /*
+ * Open the file dialog
+ */
+
+ openFileDialog () {
+ this.emit('openFileDialog')
+ }
+})
+
+module.exports = Controller
diff --git a/app/Server.js b/app/Server.js
new file mode 100644
index 0000000..4ce54a0
--- /dev/null
+++ b/app/Server.js
@@ -0,0 +1,191 @@
+'use strict'
+
+const http = require('http')
+const util = require('util')
+const rangeParser = require('range-parser')
+const pump = require('pump')
+const network = require('network-address')
+const EventEmitter = require('events').EventEmitter
+const playerEvents = require('./players/playerEvents')
+const multicastdns = require('multicast-dns')
+const request = require('request')
+const JSONStream = require('JSONStream')
+const eos = require('end-of-stream')
+
+const mdns = multicastdns()
+
+function Server (controller, follow, cb) {
+ this.controller = controller
+
+ this.server = http.createServer(this.route.bind(this)).listen(0, () => {
+ const path = this.getPath()
+ console.log('Playback server running at: ' + path)
+ cb(path)
+ })
+
+ if (follow) {
+ this._startMDNSFollow()
+ } else {
+ this._startMDNSListen()
+ }
+}
+
+Object.assign(Server.prototype, {
+
+ route (req, res) {
+ if (req.headers.origin) res.setHeader('Access-Control-Allow-Origin', req.headers.origin)
+ if (req.url === '/follow') return this.handleFollow(req, res)
+ if (req.url.endsWith('/subtitles')) return this.handleSubtitles(req, res)
+ return this.handleFile(req, res)
+ },
+
+ handleSubtitles (req, res) {
+ const fileId = decodeURIComponent(req.url.split('/')[1])
+ const file = this.controller.getFile(fileId)
+
+ if (!file) {
+ res.statusCode = 404
+ res.end()
+ return
+ }
+
+ const buf = file.subtitles
+
+ if (buf) {
+ res.setHeader('Content-Type', 'text/vtt; charset=utf-8')
+ res.setHeader('Content-Length', buf.length)
+ res.end(buf)
+ } else {
+ res.statusCode = 404
+ res.end()
+ }
+ },
+
+ handleFollow (req, res) {
+ const stringify = JSONStream.stringify()
+ const state = this.controller.getState()
+
+ stringify.pipe(res)
+
+ stringify.write({ type: 'update', arguments: [state] })
+
+ // Initial sync
+ const currentFile = state.currentFile
+ const status = state.status
+ const currentTime = state.currentTime
+
+ if (status !== this.controller.STATUS_STOPPED) {
+ stringify.write({ type: 'start', arguments: [currentFile, status === this.controller.STATUS_PLAYING, currentTime] })
+ }
+
+ const listeners = {}
+
+ // Add listeners
+ playerEvents.forEach((f) => {
+ const l = function () {
+ stringify.write({ type: f, arguments: Array.prototype.slice.call(arguments) })
+ }
+ listeners[f] = l
+ this.controller.on(f, l)
+ })
+
+ // Remove listeners on eos
+ eos(res, () => {
+ playerEvents.forEach((f) => {
+ this.controller.removeListener(f, listeners[f])
+ })
+ })
+ },
+
+ handleFile (req, res) {
+ const fileId = decodeURIComponent(req.url.split('/')[1])
+ const file = this.controller.getFile(fileId)
+
+ if (!file) {
+ res.statusCode = 404
+ res.end()
+ return
+ }
+
+ const range = req.headers.range && rangeParser(file.length, req.headers.range)[0]
+
+ res.setHeader('Accept-Ranges', 'bytes')
+ res.setHeader('Content-Type', 'video/mp4')
+
+ if (!range) {
+ res.setHeader('Content-Length', file.length)
+ if (req.method === 'HEAD') return res.end()
+ pump(file.createReadStream(), res)
+ return
+ }
+
+ res.statusCode = 206
+ res.setHeader('Content-Length', range.end - range.start + 1)
+ res.setHeader('Content-Range', 'bytes ' + range.start + '-' + range.end + '/' + file.length)
+
+ if (req.method === 'HEAD') return res.end()
+
+ pump(file.createReadStream(range), res)
+ },
+
+ getPath () {
+ return `http://${network()}:${this.server.address().port}`
+ },
+
+ _startMDNSListen () {
+ mdns.on('query', (query) => {
+ const valid = query.questions.some(function (q) {
+ return q.name === 'playback'
+ })
+
+ if (!valid) return
+
+ mdns.respond({
+ answers: [{
+ type: 'SRV',
+ ttl: 5,
+ name: 'playback',
+ data: { port: this.server.address().port, target: network() }
+ }]
+ })
+ })
+ },
+
+ _startMDNSFollow () {
+ // query for playback server
+ const query = () => {
+ mdns.query({
+ questions: [{
+ name: 'playback',
+ type: 'SRV'
+ }]
+ })
+ }
+
+ // query every 5 seconds
+ const interval = setInterval(query, 5000)
+ query()
+
+ // check if a response is from playback, then stream /follow
+ const self = this
+ mdns.on('response', function onresponse (response) {
+ response.answers.forEach((a) => {
+ if (a.name !== 'playback') return
+ clearInterval(interval)
+ mdns.removeListener('response', onresponse)
+
+ request('http://' + a.data.target + ':' + a.data.port + '/follow').pipe(JSONStream.parse('*')).on('data', (data) => {
+ if (playerEvents.indexOf(data.type) > -1) {
+ self.controller[data.type].apply(self.controller, data.arguments)
+ } else if (data.type === 'update') {
+ self.controller.setState(data.arguments[0])
+ }
+ })
+ })
+ })
+ }
+})
+
+util.inherits(Server, EventEmitter)
+
+module.exports = Server
diff --git a/app/app.js b/app/app.js
new file mode 100644
index 0000000..5b8b059
--- /dev/null
+++ b/app/app.js
@@ -0,0 +1,205 @@
+'use strict'
+
+const electron = require('electron')
+const app = electron.app
+const dialog = electron.dialog
+const BrowserWindow = electron.BrowserWindow
+const powerSaveBlocker = electron.powerSaveBlocker
+const globalShortcut = electron.globalShortcut
+const shell = electron.shell
+const Menu = electron.Menu
+const MenuItem = electron.MenuItem
+const ipc = electron.ipcMain
+
+const path = require('path')
+
+const minimist = require('minimist')
+const clipboard = require('clipboard')
+const Controller = require('./Controller')
+
+const argv = minimist(process.argv.slice(2), {
+ alias: { follow: 'f', player: 'p' },
+ string: ['player'],
+ boolean: ['follow']
+})
+
+const argURIs = argv._
+
+const allowSleep = () => {
+ if (typeof app.sleepId !== 'undefined') {
+ powerSaveBlocker.stop(app.sleepId)
+ delete app.sleepId
+ }
+}
+
+const preventSleep = () => {
+ if (typeof app.sleepId === 'undefined') {
+ app.sleepId = powerSaveBlocker.start('prevent-display-sleep')
+ }
+}
+
+app.on('ready', () => {
+ let win
+
+ const controller = new Controller(argv.follow)
+
+ controller.on('ready', (serverPath) => {
+ win = new BrowserWindow({
+ title: 'playback',
+ frame: process.platform !== 'darwin',
+ width: 860,
+ height: 470
+ })
+
+ win.loadURL(path.join('file://', __dirname, '/front/index.html#', encodeURIComponent(serverPath)))
+ win.on('closed', () => {
+ win = null
+ })
+
+ // Client loaded
+ ipc.on('clientReady', () => {
+ controller.setPlayer(argv.player || controller.PLAYER_HTML)
+ if (argURIs.length) {
+ controller.loadFiles(argURIs)
+ }
+ })
+
+ controller.on('openFileDialog', () => {
+ dialog.showOpenDialog({ properties: ['openFile', 'multiSelections'] }, (files) => {
+ if (files) controller.loadFiles(files)
+ })
+ })
+
+ // Window controls
+ ipc.on('close', () => win.close())
+ ipc.on('minimize', () => win.minimize())
+ ipc.on('maximize', () => win.maximize())
+ ipc.on('showContextMenu', () => {
+ const menu = new Menu()
+
+ menu.append(new MenuItem({
+ label: 'Always on top',
+ type: 'checkbox',
+ checked: win.isAlwaysOnTop(),
+ click: () => win.setAlwaysOnTop(!win.isAlwaysOnTop())
+ }))
+
+ menu.append(new MenuItem({
+ label: 'Paste link from clipboard',
+ click: () => controller.loadFiles(clipboard.readText().split('\n'))
+ }))
+
+ menu.append(new MenuItem({
+ label: 'Toggle subtitles',
+ click: () => controller.toggleSubtitles()
+ }))
+
+ menu.popup(win)
+ })
+
+ // Prevent/allow computer sleep
+ controller.on('preventSleep', () => preventSleep())
+ controller.on('allowSleep', () => allowSleep())
+
+ // Media keybaord shortcut handlers
+ globalShortcut.register('mediaplaypause', () => controller.togglePlay())
+ globalShortcut.register('medianexttrack', () => controller.next())
+ globalShortcut.register('mediaprevioustrack', () => controller.previous())
+
+ // Remote IPC send
+ controller.REMOTE_SEND.forEach((f) => {
+ controller.on(f, function () {
+ const newArgs = [f, controller.state.player].concat(Array.prototype.slice.call(arguments))
+ console.log(`Sending ipc event '${f}'`)
+ win.webContents.send.apply(win.webContents, newArgs)
+ })
+ })
+
+ // Remote IPC Receive
+ controller.REMOTE_RECEIVE.forEach((f) => {
+ ipc.on(f, function (sender) {
+ console.log(`Received ipc event '${f}'`)
+ controller[f].apply(controller, Array.prototype.slice.call(arguments, 1))
+ })
+ })
+
+ // Build app menu
+ const menuTemplate = [{
+ label: 'Playback',
+ submenu: [{
+ label: 'About Playback',
+ click () {
+ shell.openExternal('https://mafintosh.github.io/playback/')
+ }
+ }, {
+ type: 'separator'
+ }, {
+ label: 'Quit',
+ accelerator: 'Command+Q',
+ click () {
+ app.quit()
+ }
+ }]
+ }, {
+ label: 'File',
+ submenu: [{
+ label: 'Add media',
+ accelerator: 'Command+O',
+ click () {
+ controller.openFileDialog()
+ }
+ }, {
+ label: 'Add link from clipboard',
+ accelerator: 'CommandOrControl+V',
+ click () {
+ controller.loadFiles(clipboard.readText().split('\n'))
+ }
+ }]
+ }, {
+ label: 'Window',
+ submenu: [{
+ label: 'Minimize',
+ accelerator: 'CmdOrCtrl+M',
+ role: 'minimize'
+ }, {
+ label: 'Close',
+ accelerator: 'CmdOrCtrl+W',
+ role: 'close'
+ }, {
+ type: 'separator'
+ }, {
+ label: 'Toggle Developer Tools',
+ accelerator: process.platform === 'darwin' ? 'Alt+Command+I' : 'Ctrl+Shift+I',
+ click () {
+ win.webContents.openDevTools()
+ }
+ }]
+ }, {
+ label: 'Help',
+ submenu: [{
+ label: 'Report Issue',
+ click () {
+ shell.openExternal('https://github.com/mafintosh/playback/issues')
+ }
+ }, {
+ label: 'View Source Code on GitHub',
+ click () {
+ shell.openExternal('https://github.com/mafintosh/playback')
+ }
+ }, {
+ type: 'separator'
+ }, {
+ label: 'Releases',
+ click () {
+ shell.openExternal('https://github.com/mafintosh/playback/releases')
+ }
+ }]
+ }]
+
+ const appMenu = Menu.buildFromTemplate(menuTemplate)
+ Menu.setApplicationMenu(appMenu)
+ })
+})
+
+app.on('window-all-closed', () => app.quit())
+app.on('will-quit', () => allowSleep())
diff --git a/app/front/UI.jsx b/app/front/UI.jsx
new file mode 100644
index 0000000..cbc1f21
--- /dev/null
+++ b/app/front/UI.jsx
@@ -0,0 +1,395 @@
+'use strict'
+
+const handleDrop = require('drag-and-drop-files')
+const React = require('react')
+const reactDOM = require('react-dom')
+const render = reactDOM.render
+const findDOMNode = reactDOM.findDOMNode
+const Slider = require('react-slider')
+const CSSTG = require('react-addons-css-transition-group')
+
+const Icon = require('./components/icon.jsx')
+const Titlebar = require('./components/titlebar.jsx')
+const handleIdle = require('./utils/mouseidle.js')
+const HTMLPlayer = require('../players/HTML.js')
+
+const UI = React.createClass({
+
+ propTypes: {
+ emitter: React.PropTypes.object.isRequired
+ },
+
+ getInitialState () {
+ return {
+ playlist: [],
+ chromecasts: [],
+ volume: 1
+ }
+ },
+
+ componentDidMount () {
+ const el = findDOMNode(this)
+
+ handleIdle(el, 2500, 'hide')
+ handleDrop(document.body, (files) => this._handleLoadFilesEvent(null, files.map((f) => f.path)))
+
+ document.addEventListener('dblclick', (e) => {
+ if (el.contains(e.target)) return
+ this._handleFullscreenClick()
+ })
+
+ document.addEventListener('click', (e) => {
+ if (el.contains(e.target)) return
+ this.setState({ uiDialog: null })
+ })
+
+ document.addEventListener('keydown', (e) => {
+ if (el.contains(e.target)) return
+ if (e.keyCode === 27 && this.state.uiFullscreen) return this._handleFullscreenClick(e)
+ if (e.keyCode === 13 && e.metaKey) return this._handleFullscreenClick(e)
+ if (e.keyCode === 13 && e.shiftKey) return this._handleFullscreenClick(e)
+ if (e.keyCode === 32) return this._handleTogglePlayClick(e)
+ })
+
+ document.addEventListener('paste', (e) => {
+ this._handleLoadFilesEvent(null, e.clipboardData.getData('text').split('\n'))
+ })
+
+ document.addEventListener('webkitfullscreenchange', () => {
+ this.setState({ uiFullscreen: document.webkitIsFullScreen })
+ })
+
+ document.addEventListener('contextmenu', () => {
+ this._handleContextMenu()
+ })
+
+ const emitter = this.props.emitter
+ this.emitter = emitter
+
+ this._htmlPlayer = new HTMLPlayer(document.getElementById('video'), emitter)
+
+ emitter.on('update', (player, state) => {
+ this.setState(state)
+ })
+ },
+
+ componentWillUpdate (nextProps, nextState) {
+ if (nextState.videoWidth && this.state.videoWidth !== nextState.videoWidth) {
+ if (!document.webkitIsFullScreen) {
+ window.resizeTo(window.innerWidth, nextState.videoHeight / nextState.videoWidth * window.innerWidth | 0)
+ }
+ }
+ },
+
+ _handleTogglePlayClick () {
+ if (this.state.currentFile) {
+ this.emitter.emit('togglePlay')
+ }
+ },
+
+ _handlePlaylistIconClick () {
+ this.setState({ uiDialog: this.state.uiDialog === 'playlist' ? null : 'playlist' })
+ },
+
+ _handleChromecastIconClick () {
+ this.emitter.emit('updateChromecasts')
+ this.setState({ uiDialog: this.state.uiDialog === 'chromecasts' ? null : 'chromecasts' })
+ },
+
+ _handleRefreshChromecastsClick () {
+ this.emitter.emit('updateChromecasts')
+ },
+
+ _handleCastItemClick (device) {
+ this.setState({ uiDialog: null })
+ this.emitter.emit('setPlayer', 'chromecast', { deviceId: device.id })
+ },
+
+ _handlePlaylistItemClick (file) {
+ this.setState({ uiDialog: null })
+ this.emitter.emit('start', file, true)
+ },
+
+ _handlePlaylistRemoveItemClick (file, index, e) {
+ e.stopPropagation()
+ this.emitter.emit('remove', index)
+ },
+
+ _handleSeek (e) {
+ const percentage = e.clientX / window.innerWidth
+ const time = this.state.duration * percentage
+ this.emitter.emit('seek', time)
+ },
+
+ _handleVolumeIconClick () {
+ this.emitter.emit('setMuted', !this.state.muted)
+ },
+
+ _handleVolumeChange (val) {
+ this.emitter.emit('setVolume', val / 100)
+ },
+
+ _handleFullscreenClick () {
+ if (document.webkitIsFullScreen) {
+ document.webkitExitFullscreen()
+ } else {
+ document.body.webkitRequestFullScreen()
+ }
+ },
+
+ _handleMaximizeClick () {
+ this.emitter.emit('maximize')
+ },
+
+ _handleMinimizeClick () {
+ this.emitter.emit('minimize')
+ },
+
+ _handleCloseClick () {
+ this.emitter.emit('close')
+ },
+
+ _handleAddMediaClick () {
+ this.emitter.emit('openFileDialog')
+ },
+
+ _handleSubtitlesClick () {
+ this.emitter.emit('toggleSubtitles')
+ },
+
+ _handleLoadFilesEvent (e, files) {
+ this.setState({ uiDialog: null })
+ this.emitter.emit('loadFiles', files)
+ },
+
+ _handleTimelineMouseMove (e) {
+ this.setState({ uiTimelineTooltipPosition: e.clientX })
+ },
+
+ _handleContextMenu () {
+ this.emitter.emit('showContextMenu')
+ },
+
+ _formatTime (totalSeconds) {
+ const hours = (totalSeconds / 3600) | 0
+ let mins = ((totalSeconds - hours * 3600) / 60) | 0
+ let secs = (totalSeconds - (3600 * hours + 60 * mins)) | 0
+ if (mins < 10) mins = '0' + mins
+ if (secs < 10) secs = '0' + secs
+ return (hours ? hours + ':' : '') + mins + ':' + secs
+ },
+
+ _renderPlaylist () {
+ let items = this.state.playlist.map((file, i) => {
+ const active = file === this.state.currentFile ? 'active' : ''
+ let icon
+ if (active) {
+ icon =
+ } else {
+ icon = i + 1
+ }
+
+ return (
+
+ {icon}
+ {file.name}
+
+
+ )
+ })
+
+ if (!items.length) {
+ items = Play queue empty
+ }
+
+ return (
+
+ )
+ },
+
+ _renderChromecastDialog () {
+ let items = this.state.chromecasts.map((d, i) => {
+ const active = d.id === this.state.casting ? 'active' : ''
+ return {d.name}
+ })
+
+ if (!items.length) {
+ items = [No chromecasts found. Click to refresh.]
+ }
+
+ return (
+
+ )
+ },
+
+ _renderBuffers () {
+ const bufferedBars = []
+ if (this.state.buffered) {
+ this.state.buffered.forEach((b, i) => {
+ bufferedBars.push(
+
+ )
+ })
+ }
+ return bufferedBars
+ },
+
+ _renderDialogs () {
+ let dialog
+ if (this.state.uiDialog === 'playlist') {
+ dialog = this._renderPlaylist()
+ } else if (this.state.uiDialog === 'chromecasts') {
+ dialog = this._renderChromecastDialog()
+ }
+ return dialog
+ },
+
+ _renderEmptyState () {
+ let emptyState
+ if (!this.state.playlist.length && !this.state.loading) {
+ emptyState = (
+
+
Drop media here
+
+
+
+
+
+ )
+ }
+ return emptyState
+ },
+
+ _renderTimelineTooltip () {
+ let timelineTooltip
+ if (this.state.uiTimelineTooltipPosition) {
+ const minLeft = 25
+ const maxRight = window.innerWidth - 25
+ const value = this._formatTime(this.state.uiTimelineTooltipPosition / window.innerWidth * this.state.duration)
+ timelineTooltip = (
+ {value}
+ )
+ }
+ return timelineTooltip
+ },
+
+ _renderLoadingToast () {
+ let loading
+ if (this.state.loading) {
+ loading = (
+
+ )
+ }
+ return loading
+ },
+
+ render () {
+ const playing = this.state.status === 'playing'
+ const playIcon = playing ? 'pause' : 'play'
+ const title = this.state.currentFile ? this.state.currentFile.name : 'No file'
+ const currentTime = this.state.currentTime
+ const duration = this.state.duration
+ const updateSpeed = playing ? 500 : 0
+ const progressTime = playing ? currentTime : currentTime
+ const progressStyle = {
+ transition: `width ${updateSpeed}ms linear`,
+ width: duration ? progressTime / duration * 100 + '%' : '0'
+ }
+
+ const bufferedBars = this._renderBuffers()
+ const dialog = this._renderDialogs()
+ const loading = this._renderLoadingToast()
+ const emptyState = this._renderEmptyState()
+ const timelineTooltip = this._renderTimelineTooltip()
+
+ const hasSubtitles = this.state.currentFile && this.state.currentFile.subtitles
+ const showingSubtitles = this.state.showSubtitles
+
+ let volumeIcon
+ if (this.state.volume === 0 || this.state.muted) {
+ volumeIcon = 'volume-off'
+ } else if (this.state.volume > 0.5) {
+ volumeIcon = 'volume-up'
+ } else if (this.state.volume > 0) {
+ volumeIcon = 'volume-down'
+ }
+
+ let bufferingIcon
+ if (this.state.buffering) {
+ bufferingIcon = (
+
+ Buffering
+
+ )
+ }
+
+ let titlebar
+ if (process.platform === 'darwin') {
+ titlebar =
+ }
+
+ const app = (
+
+ {loading}
+ {emptyState}
+ {titlebar}
+
+ {dialog}
+
+
+
+ {timelineTooltip}
+ {bufferedBars}
+
+
+
+
+
+
{title}
+
+ {this._formatTime(currentTime)} / {this._formatTime(duration)}
+
+ {bufferingIcon}
+
+
+
+
+
+
+
+ )
+ return app
+ }
+})
+
+module.exports = {
+ init: (emitter, cb) => {
+ render(, document.getElementById('react-root'), cb)
+ }
+}
diff --git a/app/front/components/Icon.jsx b/app/front/components/Icon.jsx
new file mode 100644
index 0000000..0f8c320
--- /dev/null
+++ b/app/front/components/Icon.jsx
@@ -0,0 +1,107 @@
+'use strict'
+
+const React = require('react')
+
+module.exports = React.createClass({
+
+ propTypes: {
+ icon: React.PropTypes.string.isRequired,
+ size: React.PropTypes.oneOfType([
+ React.PropTypes.string,
+ React.PropTypes.number
+ ]),
+ style: React.PropTypes.object
+ },
+
+ getDefaultProps () {
+ return {
+ size: 24
+ }
+ },
+
+ _renderGraphic () {
+ switch (this.props.icon) {
+ case 'play':
+ return (
+
+ )
+ case 'pause':
+ return (
+
+ )
+ case 'playlist-empty':
+ return (
+
+ )
+ case 'playlist':
+ return (
+
+ )
+ case 'cast':
+ return (
+
+ )
+ case 'cast-connected':
+ return (
+
+ )
+ case 'fullscreen':
+ return (
+
+ )
+ case 'fullscreen-exit':
+ return (
+
+ )
+ case 'volume-up':
+ return (
+
+ )
+ case 'volume-mute':
+ return (
+
+ )
+ case 'volume-down':
+ return (
+
+ )
+ case 'volume-off':
+ return (
+
+ )
+ case 'file-download':
+ return (
+
+ )
+ case 'closed-caption':
+ return (
+
+ )
+ case 'highlight-remove':
+ return (
+
+ )
+ case 'airplay':
+ return (
+
+ )
+ default:
+ return
+ }
+ },
+
+ render () {
+ const styles = {
+ fill: 'currentcolor',
+ verticalAlign: 'middle',
+ width: this.props.size, // CSS instead of the width attr to support non-pixel units
+ height: this.props.size // Prevents scaling issue in IE
+ }
+
+ return (
+
+ )
+ }
+})
diff --git a/app/front/components/Titlebar.jsx b/app/front/components/Titlebar.jsx
new file mode 100644
index 0000000..6669185
--- /dev/null
+++ b/app/front/components/Titlebar.jsx
@@ -0,0 +1,111 @@
+'use strict'
+
+const React = require('react')
+
+const KEYCODE_ALT = 18
+
+module.exports = React.createClass({
+ propTypes: {
+ isFullscreen: React.PropTypes.bool,
+ onClose: React.PropTypes.func,
+ onMaximize: React.PropTypes.func,
+ onMinimize: React.PropTypes.func,
+ onFullscreen: React.PropTypes.func
+ },
+
+ getDefaultProps () {
+ return {
+ isFullscreen: false,
+ onClose: () => { },
+ onMaximize: () => { },
+ onMinimize: () => { },
+ onFullscreen: () => { }
+ }
+ },
+
+ getInitialState () {
+ return {
+ altDown: false
+ }
+ },
+
+ componentDidMount () {
+ document.addEventListener('keydown', this._handleKeyDown)
+ document.addEventListener('keyup', this._handleKeyUp)
+ },
+
+ componentWillUnMount () {
+ document.removeEventListener('keydown', this._handleKeyDown)
+ document.removeEventListener('keyup', this._handleKeyUp)
+ },
+
+ _handleKeyDown (e) {
+ if (e.keyCode === KEYCODE_ALT) {
+ this.setState({ altDown: true })
+ }
+ },
+
+ _handleKeyUp (e) {
+ if (e.keyCode === KEYCODE_ALT) {
+ this.setState({ altDown: false })
+ }
+ },
+
+ _handleMaximize () {
+ this.props.onMaximize()
+ },
+
+ _handleClose () {
+ this.props.onClose()
+ },
+
+ _handleMinimize () {
+ this.props.onMinimize()
+ },
+
+ _handleMaximizeOrFullscreen (e) {
+ if (e.altKey) {
+ this.props.onMaximize()
+ } else {
+ this.props.onFullscreen()
+ }
+ },
+
+ render () {
+ let maxOrFullIcon
+ if (this.state.altDown) {
+ maxOrFullIcon = (
+
+ )
+ } else {
+ maxOrFullIcon = (
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+
+ {maxOrFullIcon}
+
+
+
+ )
+ }
+})
diff --git a/splash.gif b/app/front/images/splash.gif
similarity index 100%
rename from splash.gif
rename to app/front/images/splash.gif
diff --git a/app/front/index.html b/app/front/index.html
new file mode 100644
index 0000000..e417c2d
--- /dev/null
+++ b/app/front/index.html
@@ -0,0 +1,19 @@
+
+
+
+
+ Playback
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/front/index.js b/app/front/index.js
new file mode 100644
index 0000000..2a159dc
--- /dev/null
+++ b/app/front/index.js
@@ -0,0 +1,20 @@
+'use strict'
+
+const UI = require('./UI.jsx')
+const ipc = require('electron').ipcRenderer
+
+// Wrap ipc in a simplified EventEmitter api.
+// This is done so that we can eventually swap between a
+// ipc or a websocket connection for remote control
+const emitter = {
+ on (channel, cb) {
+ ipc.on(channel, function (sender) {
+ cb.apply(null, Array.prototype.slice.call(arguments, 1))
+ })
+ },
+ emit: ipc.send
+}
+
+UI.init(emitter, () => {
+ emitter.emit('clientReady')
+})
diff --git a/app/front/styles/main.scss b/app/front/styles/main.scss
new file mode 100644
index 0000000..5a37625
--- /dev/null
+++ b/app/front/styles/main.scss
@@ -0,0 +1,485 @@
+@import 'titlebar';
+
+$body-bg: black;
+$body-font: Arial;
+$primary: #579A57;
+$controls-bg: rgba(0, 0, 0, 0.84);
+$controls-fg: #aaa;
+$controls-icon-color: #fff;
+$controls-icon-on-color: $primary;
+$controls-icon-color-hover: $primary;
+$controls-timeline-height: 6px;
+$controls-timeline-hover-height: 10px;
+$controls-timeline-bg: rgba(218, 218, 218, 0.12);
+$controls-timeline-progress-bg: $primary;
+$controls-timeline-buffered-bg: rgba(218, 218, 218, 0.12);
+
+$controls-timeline-tooltip-bg: rgba(0,0,0,.95);
+$controls-timeline-tooltip-fg: #fff;
+
+$toast-bg: $controls-bg;
+$toast-fg: $controls-fg;
+
+$playlist-bg: rgba(20,20,20,.95);
+$playlist-fg: $controls-fg;
+$playlist-button-bg: $primary;
+$playlist-button-fg: #fff;
+$playlist-button-hover-bg: lighten($primary, 10%);
+$playlist-button-hover-fg: #fff;
+$playlist-item-icon-fg: #999;
+$playlist-item-hover-bg: rgba(255,255,255,.05);
+$playlist-item-hover-fg: #fff;
+$playlist-item-active-bg: rgba(255,255,255,.15);
+$playlist-item-active-fg: #fff;
+
+$btn-bg: $primary;
+$btn-fg: #fff;
+$btn-hover-bg: lighten($primary, 10%);
+$btn-hover-fg: #fff;
+
+$slider-height: 26px;
+$slider-bar-height: 4px;
+$slider-width: 75px;
+$slider-bar-bg: rgba(255,255,255,.1);
+$slider-bar-active-bg: $primary;
+$slider-handle-bg: #fff;
+$slider-handle-width: 4px;
+$slider-handle-height: 20px;
+
+*, *:after, *:before {
+ box-sizing: inherit;
+}
+
+body {
+ background: $body-bg;
+ overflow: hidden;
+ font-family: $body-font;
+ font-size: 13px;
+ margin: 0;
+ cursor: default;
+ box-sizing: border-box;
+ width: 100%;
+ height: 100%;
+}
+
+body.hide {
+ cursor: none;
+}
+
+.ui {
+ transition: opacity 250ms ease-in-out;
+ position: fixed;
+}
+
+.ui.hide:not(.stopped) {
+ opacity: 0;
+
+ .controls {
+ bottom: -50px;
+ }
+
+ .dialog {
+ right: -20px;
+ }
+
+ .titlebar {
+ top: -30px;
+ }
+}
+
+.ui.stopped {
+ .controls > .controls__timeline:hover > .controls__timeline__tooltip {
+ opacity: 0;
+ }
+}
+
+video {
+ -webkit-app-region: drag;
+ position: fixed;
+ width: 100%;
+ height: 100%;
+}
+
+.canvas-container {
+ position: fixed;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+canvas {
+ -webkit-app-region: drag;
+ width: 100%;
+ max-height: 100%;
+}
+
+.ui, .controls, .dialog {
+ -webkit-app-region: no-drag;
+}
+
+.btn {
+ background: $btn-bg;
+ color: $btn-fg;
+ padding: 8px 16px;
+ font-weight: 600;
+ border: none;
+ border-radius: 2px;
+ font-family: inherit;
+
+ &:hover {
+ background: $btn-hover-bg;
+ color: $btn-hover-fg;
+ }
+}
+
+
+.dialog {
+ position: fixed;
+ bottom: 55px;
+ right: 10px;
+ max-height: calc(100% - 100px);
+ width: 400px;
+ background: $playlist-bg;
+ color: $playlist-fg;
+ display: flex;
+ flex-direction: column;
+ z-index: 2;
+ transition: right 250ms ease-in-out;
+
+ > ul {
+ margin: 0;
+ padding: 6px;
+ list-style: none;
+ flex: 1;
+
+ &:empty {
+ padding: 6px 0 0 0;
+ }
+
+ > li {
+ padding: 10px 6px;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ display: flex;
+ align-items: center;
+ min-height: 44px;
+
+ > .playlist__item-icon {
+ width: 30px;
+ text-align: center;
+ margin-right: 6px;
+ color: $playlist-item-icon-fg;
+ }
+
+ > .playlist__item-title {
+ flex: 1;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ > .playlist__item-action {
+ width: 0;
+ opacity: 0;
+ overflow: hidden;
+ transition: opacity 125ms ease-in-out, width 125ms ease-in-out, color 125ms ease-in-out;
+ color: rgba(255,255,255,.2);
+ }
+
+ &:hover {
+ background-color: $playlist-item-hover-bg;
+ color: $playlist-item-hover-fg;
+
+ > .playlist__item-action {
+ width: 28px;
+ opacity: 1;
+
+ &:hover {
+ color: #fff;
+ }
+ }
+ }
+
+ &.active {
+ background-color: $playlist-item-active-bg;
+ color: $playlist-item-active-fg;
+ }
+ }
+ }
+
+ > div {
+ padding: 0 6px 6px 6px;
+ }
+
+ > div > .btn {
+ width: 100%;
+ }
+}
+
+.controls {
+ position: fixed;
+ bottom: 0;
+ right: 0;
+ left: 0;
+ background-color: $controls-bg;
+ color: $controls-fg;
+ transition: opacity 250ms ease-in-out, bottom 250ms ease-in-out;
+
+ .controls__timeline {
+ height: $controls-timeline-height;
+ width: 100%;
+ background: $controls-timeline-bg;
+ position: relative;
+ z-index: 1;
+ top: 0;
+ margin-top: 0;
+ transition: height 125ms ease-in-out, top 125ms ease-in-out, margin-top 125ms ease-in-out;
+
+ // hit target
+ &:before {
+ content: "";
+ position: absolute;
+ top: -4px;
+ bottom: -4px;
+ width: 100%;
+ }
+
+ &:hover {
+ height: $controls-timeline-hover-height;
+ top: ($controls-timeline-hover-height - $controls-timeline-height) / 2;
+ margin-top: ($controls-timeline-hover-height - $controls-timeline-height) / -2;
+ }
+
+ > .controls__timeline__progress {
+ position: absolute;
+ height: 100%;
+ background-color: $controls-timeline-progress-bg;
+ }
+
+ > .controls__timeline__buffered {
+ position: absolute;
+ height: 100%;
+ background-color: $controls-timeline-buffered-bg;
+ }
+
+ > .controls__timeline__tooltip {
+ background: $controls-timeline-tooltip-bg;
+ border-radius: 3px;
+ padding: 4px 8px;
+ color: $controls-timeline-tooltip-fg;
+ text-align: center;
+ font-size: 12px;
+ position: absolute;
+ z-index: 100;
+ opacity: 0;
+ transition: opacity 250ms ease-in-out;
+ transform: translate(-50%, -28px);
+
+ &:before {
+ transition: height 125ms ease-in-out;
+ content: "";
+ width: 2px;
+ height: $controls-timeline-height;
+ background-color: rgba(255,255,255,.5);
+ left: calc(50% - 1px);
+ top: 28px;
+ display: block;
+ position: absolute;
+ z-index: 1;
+ }
+
+ &:after {
+ width: 0;
+ height: 0;
+ top: 100%;
+ left: 50%;
+ border-left: 6px solid transparent;
+ border-right: 6px solid transparent;
+ border-top: 6px solid $controls-timeline-tooltip-bg;
+ content: "";
+ margin-left: -6px;
+ position: absolute;
+ }
+ }
+
+ &:hover {
+ .controls__timeline__tooltip {
+ opacity: 1;
+
+ &:before {
+ height: $controls-timeline-hover-height;
+ }
+
+ }
+ }
+ }
+
+ .controls__toolbar {
+ padding: 6px;
+ display: flex;
+ align-items: center;
+
+ button {
+ background: transparent;
+ border: none;
+ outline: none;
+ color: $controls-icon-color;
+ transition: color 125ms ease-in-out;
+
+ &:hover:not(:disabled) {
+ color: $controls-icon-color-hover;
+ }
+
+ &:disabled {
+ opacity: .4;
+ }
+
+ &.muted {
+ opacity: .4;
+ }
+
+ &.on {
+ color: $controls-icon-on-color;
+ }
+ }
+
+ .controls__title {
+ flex: 1;
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+ }
+
+ .controls__metadata {
+ margin-right: 10px;
+ }
+
+ .controls__toolbar__volume {
+ display: inline-block;
+ position: relative;
+
+ &:hover {
+ // hit target
+ &:before {
+ content: "";
+ position: absolute;
+ left: 0;
+ right: -100px;
+ top: -20px;
+ bottom: -10px;
+ }
+
+ > button {
+ position: relative;
+ z-index: 1;
+ }
+
+ .controls__toolbar__volume__slider {
+ opacity: 1;
+ width: $slider-width + 4px;
+ padding: 0 4px;
+ margin-right: 16px;
+ }
+ }
+ }
+
+ .controls__toolbar__volume__slider {
+ margin-right: 0;
+ display: inline-block;
+ vertical-align: middle;
+ opacity: 0;
+ width: 0;
+ overflow: hidden;
+ transition: width 250ms ease-in-out, opacity 250ms ease-in-out, margin-right 250ms ease-in-out;
+ z-index: 100;
+ border-radius: 2px;
+ padding: 0 4px;
+ }
+ }
+}
+
+.toast-container {
+ position: fixed;
+ display: flex;
+ height: 100%;
+ width: 100%;
+ z-index: 1000;
+ align-items: center;
+ justify-content: center;
+}
+
+.toast {
+ background: $toast-bg;
+ color: $toast-fg;
+ padding: 12px 24px;
+ box-shadow: 0px 1px 6px rgba(0,0,0,.5);
+}
+
+.slider {
+ position: relative;
+ width: $slider-width;
+ height: $slider-height;
+
+ > .bar {
+ top: ($slider-height - $slider-bar-height) / 2;
+ height: $slider-bar-height;
+ background-color: $slider-bar-bg;
+
+ &.bar-0 {
+ background-color: $slider-bar-active-bg;
+ }
+ }
+
+ > .handle {
+ height: $slider-handle-height;
+ top: ($slider-height - $slider-handle-height) / 2;
+ width: $slider-handle-width;
+ background: $slider-handle-bg;
+ }
+}
+
+.empty-state {
+ position: fixed;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ color: rgba(255,255,255,.4);
+
+ .empty-state__heading {
+ font-size: 16px;
+ margin-bottom: 20px;
+ }
+
+ .empty-state__icon {
+ border-radius: 20px;
+ padding: 80px;
+ margin-bottom: 20px;
+ background-image: url(../images/splash.gif);
+ background-repeat: no-repeat;
+ background-position: center center;
+ }
+}
+
+.fade-in-enter {
+ opacity: 0.01;
+}
+
+.fade-in-enter.fade-in-enter-active {
+ opacity: 1;
+ transition: opacity 125ms ease-in-out, right 125ms ease-in-out;
+}
+
+.fade-in-leave {
+ opacity: 1;
+}
+
+.fade-in-leave.fade-in-leave-active {
+ opacity: 0.01;
+ transition: opacity 125ms ease-in-out, right 125ms ease-in-out;
+}
diff --git a/app/front/styles/titlebar.scss b/app/front/styles/titlebar.scss
new file mode 100644
index 0000000..379899d
--- /dev/null
+++ b/app/front/styles/titlebar.scss
@@ -0,0 +1,108 @@
+.titlebar {
+ position: fixed;
+ width: 100%;
+ height: 22px;
+ background-color: rgba(0, 0, 0, 0.84);
+ display: flex;
+ align-items: center;
+ top: 0;
+ transition: top 250ms ease-in-out;
+}
+
+.titlebar__stoplight {
+ line-height: 0;
+ display: flex;
+}
+
+.titlebar__stoplight svg {
+ fill: #000;
+ opacity: 0;
+}
+
+.titlebar__stoplight:hover {
+
+ .titlebar__fullscreen-svg{
+ opacity: .65;
+ }
+
+ .titlebar__minimize-svg,
+ .titlebar__maximize-svg {
+ opacity: .9;
+ }
+
+ .titlebar__close-svg {
+ opacity: .75;
+ }
+}
+
+.titlebar__stoplight__close,
+.titlebar__stoplight__minimize,
+.titlebar__stoplight__fullscreen {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ margin: 0 0 0 8px;
+ line-height: 0;
+ -webkit-app-region: no-drag;
+ -webkit-user-select: none;
+ position: relative;
+
+ &:after {
+ content: "";
+ position: absolute;
+ height: 12px;
+ width: 12px;
+ top: 0;
+ left: 0;
+ transform: scale(.92, .92);
+ box-shadow: 0 0 0 .5px rgba(0,0,0,.2);
+ border-radius: 50%;
+ }
+}
+
+.titlebar__fullscreen-svg {
+ width: 6px;
+ height: 6px;
+}
+
+.titlebar__maximize-svg {
+ width: 8px;
+ height: 8px;
+}
+
+.titlebar__minimize-svg {
+ width: 8px;
+ height: 8px;
+}
+
+.titlebar__close-svg {
+ width: 6px;
+ height: 6px;
+}
+
+.titlebar__stoplight__close {
+ background-color: #fc635d;
+}
+
+.titlebar__stoplight__close:active {
+ background-color: #bd4a46;
+}
+
+.titlebar__stoplight__minimize {
+ background-color: #fcbc41;
+}
+
+.titlebar__stoplight__minimize:active {
+ background-color: #be8e30;
+}
+
+.titlebar__stoplight__fullscreen {
+ background-color: #35cd4b;
+}
+
+.titlebar__stoplight__fullscreen:active {
+ background-color: #279a37;
+}
diff --git a/app/front/utils/mouseidle.js b/app/front/utils/mouseidle.js
new file mode 100644
index 0000000..94d58f8
--- /dev/null
+++ b/app/front/utils/mouseidle.js
@@ -0,0 +1,28 @@
+'use strict'
+
+const debounce = require('lodash.debounce')
+
+module.exports = (elem, timeout, className) => {
+ const hide = debounce(() => {
+ elem.classList.add(className)
+ document.body.classList.add(className)
+ }, timeout)
+
+ const show = () => {
+ elem.classList.remove(className)
+ document.body.classList.remove(className)
+ }
+
+ const listener = (e) => {
+ hide.cancel()
+ show()
+ if (!elem.contains(e.target)) {
+ hide()
+ }
+ }
+
+ document.addEventListener('mousemove', listener)
+ document.addEventListener('mousedown', listener)
+ document.addEventListener('mouseup', listener)
+ document.addEventListener('mouseout', hide)
+}
diff --git a/app/loaders/file.js b/app/loaders/file.js
new file mode 100644
index 0000000..f477bcd
--- /dev/null
+++ b/app/loaders/file.js
@@ -0,0 +1,68 @@
+'use strict'
+
+const qfs = require('q-io/fs')
+const fs = require('fs')
+const vtt = require('srt-to-vtt')
+const concat = require('concat-stream')
+const path = require('path')
+
+module.exports = {
+ test () {
+ return true
+ },
+
+ /*
+ * Load the file and subtitles, if possible.
+ */
+
+ load (uri) {
+ const filePath = uri.replace(/^file:\/\//i, '')
+ const file = { uri, filePath }
+
+ return qfs.stat(filePath)
+ .then((stat) => {
+ file.length = stat.size
+ file.name = path.basename(filePath)
+ file.createReadStream = (opts) => fs.createReadStream(filePath, opts)
+ return this._getSubtitles(filePath)
+ }).then((subtitles) => {
+ file.subtitles = subtitles
+ return file
+ })
+ },
+
+ /*
+ * Load a subtitle
+ */
+
+ loadSubtitle (subtitlePath) {
+ return new Promise((resolve) => {
+ qfs.exists(subtitlePath).then((exists) => {
+ if (exists) {
+ fs.createReadStream(subtitlePath).pipe(vtt()).pipe(concat((data) => resolve(data)))
+ } else {
+ resolve()
+ }
+ }).catch(resolve)
+ })
+ },
+
+ /*
+ * Attempt to get subtitles relative to the filePath
+ */
+
+ _getSubtitles (filePath) {
+ const basename = filePath.substr(0, filePath.lastIndexOf('.'))
+ const extensions = ['srt', 'vtt']
+ const next = () => {
+ const ext = extensions.shift()
+ if (!ext) return Promise.resolve()
+
+ return this.loadSubtitle(basename + '.' + ext).then((data) => {
+ if (!data) { return next() }
+ return Promise.resolve(data)
+ })
+ }
+ return next()
+ }
+}
diff --git a/app/loaders/http.js b/app/loaders/http.js
new file mode 100644
index 0000000..abe73fa
--- /dev/null
+++ b/app/loaders/http.js
@@ -0,0 +1,36 @@
+'use strict'
+
+const request = require('request')
+
+module.exports = {
+ test (uri) {
+ return /^https?:\/\//i.test(uri)
+ },
+
+ load (uri) {
+ return new Promise((resolve, reject) => {
+ const file = { uri }
+
+ file.name = uri.lastIndexOf('/') > -1 ? uri.split('/').pop() : uri
+
+ file.createReadStream = (options) => {
+ const opts = options || {}
+ if (opts.start || opts.end) {
+ const rs = 'bytes=' + (opts.start || 0) + '-' + (opts.end || file.length || '')
+ return request(uri, { headers: { Range: rs } })
+ }
+ return request(uri)
+ }
+
+ // first, get the head for the content length.
+ // IMPORTANT: servers without HEAD will not work.
+ request.head(uri, (err, response) => {
+ if (err) return reject(err)
+ if (!/2\d\d/.test(response.statusCode)) return reject(new Error('request failed'))
+
+ file.length = Number(response.headers['content-length'])
+ resolve(file)
+ })
+ })
+ }
+}
diff --git a/app/loaders/ipfs.js b/app/loaders/ipfs.js
new file mode 100644
index 0000000..8449514
--- /dev/null
+++ b/app/loaders/ipfs.js
@@ -0,0 +1,34 @@
+'use strict'
+
+const httpLoader = require('./http')
+const fileLoader = require('./file')
+
+module.exports = {
+ test (uri) {
+ return /^\/*(ipfs|ipns)\//i.test(uri)
+ },
+
+ load (uri) {
+ let link = uri
+ if (uri[0] !== '/') link = '/' + link // may be stripped in add
+
+ const local = 'localhost:8080' // todo: make this configurable
+ const gateway = 'gateway.ipfs.io'
+
+ // first, try the local http gateway
+ let u = 'http://' + local + link
+ console.log('trying local ipfs gateway: ' + u)
+ return httpLoader.load(u)
+ .catch(() => {
+ // error? ok try fuse... maybe the gateway's broken.
+ console.log('trying mounted ipfs fs (just in case)')
+ return fileLoader.load(link)
+ })
+ .catch(() => {
+ // worst case, try global ipfs gateway.
+ u = 'http://' + gateway + link
+ console.log('trying local ipfs gateway: ' + u)
+ return httpLoader.load(u)
+ })
+ }
+}
diff --git a/app/loaders/magnet.js b/app/loaders/magnet.js
new file mode 100644
index 0000000..4017afd
--- /dev/null
+++ b/app/loaders/magnet.js
@@ -0,0 +1,69 @@
+'use strict'
+
+const torrents = require('webtorrent')
+const concat = require('concat-stream')
+const vtt = require('srt-to-vtt')
+
+module.exports = {
+ test (uri) {
+ return /magnet:/i.test(uri)
+ },
+
+ load (uriOrBuffer) {
+ return new Promise((resolve) => {
+ console.log(uriOrBuffer)
+ const engine = torrents()
+ const subtitles = {}
+
+ engine.on('error', (err) => {
+ console.error(err)
+ })
+
+ engine.add(uriOrBuffer, (torrent) => {
+ torrent.files.forEach((f) => {
+ if (/\.(vtt|srt)$/i.test(f.name)) {
+ subtitles[f.name] = f
+ }
+ })
+
+ // TODO: resolve with array of files?
+ torrent.files.some((f) => {
+ f.downloadSpeed = torrent.downloadSpeed()
+ if (/\.(mp4|mkv|mp3|mov|avi)$/i.test(f.name)) {
+ f.select()
+ f.uri = torrent.magnetURI
+ const basename = f.name.substr(0, f.name.lastIndexOf('.'))
+ const subtitle = subtitles[basename + '.srt'] || subtitles[basename + '.vtt']
+ if (subtitle) {
+ this._loadSubtitles(subtitle).then((data) => {
+ f.subtitles = data
+ resolve(f)
+ })
+ } else {
+ resolve(f)
+ }
+ return true
+ }
+ })
+
+ // torrent.on('download', (chunkSize) => {
+ // console.log('chunk size: ' + chunkSize)
+ // console.log('total downloaded: ' + torrent.downloaded)
+ // console.log('download speed: ' + torrent.downloadSpeed())
+ // console.log('progress: ' + torrent.progress)
+ // console.log('======')
+ // })
+
+ torrent.on('done', () => {
+ console.log('torrent finished downloading')
+ })
+ })
+ })
+ },
+
+ _loadSubtitles (subtitle) {
+ return new Promise((resolve) => {
+ subtitle.createReadStream().pipe(vtt()).pipe(concat((data) => resolve(data)))
+ })
+ }
+}
diff --git a/app/loaders/torrent.js b/app/loaders/torrent.js
new file mode 100644
index 0000000..fe852f3
--- /dev/null
+++ b/app/loaders/torrent.js
@@ -0,0 +1,19 @@
+'use strict'
+
+const fs = require('fs')
+const magnet = require('./magnet')
+
+module.exports = {
+ test (uri) {
+ return /\.torrent$/i.test(uri)
+ },
+
+ load (uri) {
+ return new Promise((resolve, reject) => {
+ fs.readFile(uri, function (err, link) {
+ if (err) return reject(err)
+ magnet.load(link).then(resolve).catch(reject)
+ })
+ })
+ }
+}
diff --git a/app/loaders/youtube.js b/app/loaders/youtube.js
new file mode 100644
index 0000000..307d354
--- /dev/null
+++ b/app/loaders/youtube.js
@@ -0,0 +1,64 @@
+'use strict'
+
+const ytdl = require('ytdl-core')
+const request = require('request')
+const duplex = require('duplexify')
+
+module.exports = {
+ test (uri) {
+ return /youtube\.com\/watch|youtu.be/i.test(uri)
+ },
+
+ load (uri) {
+ return new Promise((resolve, reject) => {
+ const file = { uri: uri }
+ const url = /https?:/.test(uri) ? uri : 'https:' + uri
+
+ this._getYoutubeData(url).then((data) => {
+ const fmt = data.fmt
+ let vidUrl = fmt.url
+ const info = data.info
+
+ request({ method: 'HEAD', url: vidUrl }, (err, resp) => {
+ if (err) return reject(err)
+
+ const len = resp.headers['content-length']
+ if (!len) return reject(new Error('no content-length on response'))
+ file.length = +len
+ file.name = info.title
+
+ file.createReadStream = (options) => {
+ const opts = options || {}
+ const stream = duplex()
+ this._getYoutubeData(url).then((data2) => {
+ vidUrl = data2.fmt.url
+ if (opts.start || opts.end) vidUrl += '&range=' + ([opts.start || 0, opts.end || len].join('-'))
+ stream.setReadable(request(vidUrl))
+ }).catch((err2) => reject(err2))
+ return stream
+ }
+ resolve(file)
+ })
+ })
+ })
+ },
+
+ _getYoutubeData (url) {
+ return new Promise((resolve, reject) => {
+ ytdl.getInfo(url, (err, info) => {
+ if (err) return reject(err)
+
+ const filtered = info.formats
+ .sort((a, b) => +(a.resolution > b.resolution) || +(a.resolution === b.resolution) - 1)
+ .filter((f) => f.audioEncoding && f.resolution && (f.container === 'mp4' || f.container === 'webm'))
+
+ const vidFmt = filtered[filtered.length - 1]
+
+ console.log('Choosing youtube video format: ', vidFmt.container, vidFmt.resolution)
+ if (!vidFmt) return reject(new Error('No suitable video format found'))
+
+ return resolve({ info: info, fmt: vidFmt })
+ })
+ })
+ }
+}
diff --git a/app/players/Chromecast.js b/app/players/Chromecast.js
new file mode 100644
index 0000000..e5e894b
--- /dev/null
+++ b/app/players/Chromecast.js
@@ -0,0 +1,114 @@
+'use strict'
+
+const playerEvents = require('./playerEvents')
+
+function Chromecast (controller, chromecasts) {
+ this.POLL_FREQUENCY = 1000
+
+ this.chromecasts = chromecasts
+ this.controller = controller
+
+ playerEvents.forEach((f) => {
+ controller.on(f, function () {
+ if (this.controller.state.player === 'chromecast') {
+ this[f].apply(this, Array.prototype.slice.call(arguments))
+ }
+ }.bind(this))
+ })
+}
+
+Object.assign(Chromecast.prototype, {
+ enablePlayer (id) {
+ const device = this.chromecasts.players[this.chromecasts.players.findIndex((d) => d.host + d.name === id)]
+ this.device = device
+ },
+
+ disablePlayer () {
+ this.device = null
+ },
+
+ start (file, autoPlay, currentTime, showSubtitles, volume, muted) {
+ this.active = true
+ this.device.play(file.streamUrl, {
+ autoPlay,
+ title: file.name,
+ seek: currentTime,
+ autoSubtitles: showSubtitles,
+ subtitles: file.subtitlesUrl ? [file.subtitlesUrl] : []
+ }, this._onMetadata.bind(this, volume, autoPlay))
+ },
+
+ showSubtitles () {
+ this.device.subtitles(1)
+ },
+
+ hideSubtitles () {
+ this.device.subtitles(false)
+ },
+
+ _onMetadata (volume, autoPlay, err, status) {
+ if (autoPlay) {
+ this._startPolling()
+ }
+ this.device.volume(volume)
+ this.controller.playerMetadata({ duration: status.media.duration })
+ },
+
+ _onEnd () {
+ this._stopPolling()
+ this.active = false
+ this.controller.playerEnd()
+ },
+
+ _onStatus (err, status) {
+ if (err) return
+ if (!status) {
+ this._onEnd()
+ } else {
+ this.controller.playerStatus({ currentTime: status.currentTime })
+ }
+ },
+
+ _startPolling () {
+ this._stopPolling()
+ this.interval = setInterval(() => {
+ this.device.status(this._onStatus.bind(this))
+ }, this.POLL_FREQUENCY)
+ },
+
+ _stopPolling () {
+ clearInterval(this.interval)
+ },
+
+ resume () {
+ this.device.resume()
+ this._startPolling()
+ },
+
+ pause () {
+ this._stopPolling()
+ this.device.pause()
+ },
+
+ stop () {
+ this._stopPolling()
+ if (this.active) {
+ this.active = false
+ this.device.stop()
+ }
+ },
+
+ seek (second) {
+ this.device.seek(second)
+ },
+
+ setVolume (value) {
+ this.device.volume(value)
+ },
+
+ setMuted (muted) {
+ this.device.volume(muted ? 0 : 0.5)
+ }
+})
+
+module.exports = Chromecast
diff --git a/app/players/HTML.js b/app/players/HTML.js
new file mode 100644
index 0000000..4998504
--- /dev/null
+++ b/app/players/HTML.js
@@ -0,0 +1,145 @@
+'use strict'
+
+const playerEvents = require('./playerEvents')
+
+function HTMLPlayer (element, emitter) {
+ this.POLL_FREQUENCY = 500
+
+ this.element = element
+ this._onMetadata = this._onMetadata.bind(this)
+ this._onEnd = this._onEnd.bind(this)
+ this.emitter = emitter
+
+ playerEvents.forEach((f) => {
+ emitter.on(f, function (player) {
+ if (player === 'html') {
+ this[f].apply(this, Array.prototype.slice.call(arguments, 1))
+ }
+ }.bind(this))
+ })
+
+ this.element.addEventListener('loadedmetadata', this._onMetadata)
+ this.element.addEventListener('ended', this._onEnd)
+ this.element.addEventListener('waiting', this._onWaitingChange.bind(this, 'waiting'))
+ this.element.addEventListener('playing', this._onWaitingChange.bind(this, 'playing'))
+}
+
+Object.assign(HTMLPlayer.prototype, {
+ enablePlayer () {
+ this.element.style.display = 'block'
+ },
+
+ disablePlayer () {
+ this.element.style.display = 'none'
+ },
+
+ _onWaitingChange (state) {
+ this.emitter.emit('playerStatus', {
+ buffering: state === 'waiting'
+ })
+ },
+
+ _onMetadata () {
+ this.emitter.emit('playerMetadata', {
+ duration: this.element.duration,
+ height: this.element.videoHeight,
+ width: this.element.videoWidth
+ })
+ },
+
+ _onEnd () {
+ this._stopPolling()
+ this.emitter.emit('playerEnd')
+ },
+
+ _startPolling () {
+ this._stopPolling()
+ this.interval = setInterval(() => {
+ const buffers = []
+ for (let i = 0; i < this.element.buffered.length; i++) {
+ buffers.push({
+ left: this.element.buffered.start(i) / this.element.duration * 100,
+ width: (this.element.buffered.end(i) - this.element.buffered.start(i)) / this.element.duration * 100
+ })
+ }
+ this.emitter.emit('playerStatus', {
+ currentTime: this.element.currentTime,
+ buffered: buffers
+ })
+ }, this.POLL_FREQUENCY)
+ },
+
+ _stopPolling () {
+ clearInterval(this.interval)
+ },
+
+ start (file, autoPlay, currentTime, showSubtitles, volume, muted) {
+ this.stop()
+
+ const el = this.element
+ const src = document.createElement('source')
+ src.setAttribute('src', file.streamUrl)
+ el.appendChild(src)
+ el.load()
+ el.currentTime = currentTime
+ el.volume = volume
+ el.muted = muted
+
+ if (showSubtitles) {
+ this.showSubtitles()
+ }
+
+ if (autoPlay) {
+ this.resume()
+ }
+ },
+
+ showSubtitles () {
+ if (this.element.querySelector('track')) {
+ this.element.querySelector('track').mode = 'showing'
+ } else {
+ const track = document.createElement('track')
+ track.setAttribute('default', 'default')
+ track.setAttribute('src', this.element.querySelector('source').src + '/subtitles')
+ track.setAttribute('label', 'Subtitles')
+ track.setAttribute('kind', 'subtitles')
+ this.element.appendChild(track)
+ }
+ },
+
+ hideSubtitles () {
+ this.element.querySelector('track').mode = 'hidden'
+ this.element.removeChild(this.element.querySelector('track'))
+ },
+
+ setVolume (value) {
+ this.element.volume = value
+ },
+
+ setMuted (muted) {
+ this.element.muted = muted
+ },
+
+ resume () {
+ this._startPolling()
+ this.element.play()
+ },
+
+ pause () {
+ this._stopPolling()
+ this.element.pause()
+ },
+
+ stop () {
+ this._stopPolling()
+ this.element.pause()
+ this.element.innerHTML = ''
+ this.element.load()
+ },
+
+ seek (second) {
+ this.element.currentTime = second
+ }
+})
+
+module.exports = HTMLPlayer
diff --git a/app/players/playerEvents.js b/app/players/playerEvents.js
new file mode 100644
index 0000000..826ea2d
--- /dev/null
+++ b/app/players/playerEvents.js
@@ -0,0 +1,13 @@
+module.exports = [
+ 'setMuted',
+ 'setVolume',
+ 'start',
+ 'resume',
+ 'pause',
+ 'stop',
+ 'seek',
+ 'hideSubtitles',
+ 'showSubtitles',
+ 'disablePlayer',
+ 'enablePlayer'
+]
diff --git a/chromecast.png b/chromecast.png
deleted file mode 100644
index 619dd50..0000000
Binary files a/chromecast.png and /dev/null differ
diff --git a/index.css b/index.css
deleted file mode 100644
index f881f3b..0000000
--- a/index.css
+++ /dev/null
@@ -1,370 +0,0 @@
-body, html {
- margin: 0;
- padding: 0;
- width: 100%;
- height: 100%;
- background-color: #000000;
- overflow: hidden;
- -webkit-user-select: none;
- font-family: 'Roboto', sans-serif;
- font-size: 12px;
- cursor: default;
-}
-
-.right {
- float: right;
-}
-
-.hidden {
- display: none;
-}
-
-#player {
- width: 100%;
- height: 100%;
-}
-
-#drag-video {
- z-index: 99;
- position: absolute;
- top: 24px;
- bottom: 50px;
- left: 0;
- right: 0;
-}
-
-#splash {
- width: 100%;
- height: 100%;
- background-image: url('splash.gif');
- background-size: 100%;
- background-position: -50%;
- background-repeat: no-repeat;
- -webkit-filter: grayscale(100%);
-}
-
-#overlay {
- opacity: 0;
- z-index: 10;
- position: absolute;
- top: 0;
- left: 0;
- right: 0;
- bottom: 0;
- transition: opacity 0.3s ease;
-}
-
-#popup {
- opacity: 0;
- display: none;
- position: absolute;
- z-index: 20;
- right: 5px;
- bottom: 55px;
- top: 100px;
- width: 400px;
- background-color: #1F2021;
- border-radius: 3px;
- font-size: 14px;
- transition: opacity 0.3s ease;
-}
-
-#popup.chromecast #chromecast-popup {
- display: block;
-}
-
-#popup.chromecast #playlist-popup {
- display: none;
-}
-
-#popup.playlist #playlist-popup {
- display: block;
-}
-
-#popup.playlist #chromecast-popup {
- display: none;
-}
-
-#playlist-entries, #chromecast-entries {
- position: absolute;
- top: 46px;
- bottom: 55px;
- right: 0;
- left: 0;
- overflow: auto;
-}
-
-.playlist-entry, .chromecast-entry {
- color: #fff;
- font-size: 13px;
- padding: 10px 15px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-@-webkit-keyframes spin {
- to { -webkit-transform: rotate(360deg); }
-}
-
-@-moz-keyframes spin {
- to { -moz-transform: rotate(360deg); }
-}
-
-@-ms-keyframes spin {
- to { -ms-transform: rotate(360deg); }
-}
-
-@-o-keyframes spin {
- to { -o-transform: rotate(360deg); }
-}
-
-@keyframes spin {
- to { transform: rotate(360deg); }
-}
-
-.playlist-entry .status {
- float: right;
- -webkit-animation: spin 1.5s infinite linear;
- -moz-animation: spin 1.5s infinite linear;
- -ms-animation: spin 1.5s infinite linear;
- -o-animation: spin 1.5s infinite linear;
- animation: spin 1.5s infinite linear;
-}
-
-.playlist-entry .status:after {
- -webkit-transform: rotate(45deg);
- -moz-transform: rotate(45deg);
- -ms-transform: rotate(45deg);
- -o-transform: rotate(45deg);
- transform: rotate(45deg);
-}
-
-.playlist-entry.odd, .chromecast-entry.odd {
- background-color: #222324;
-}
-
-.playlist-entry.selected, .chromecast-entry.selected {
- background-color: #31A357;
-}
-
-#popup .header {
- background-color: #363738;
- color: #E1E1E1;
- font-size: 16px;
- line-height: 16px;
- padding: 15px;
- border-radius: 3px 3px 0 0;
-}
-
-#popup .button {
- margin: 10px;
- background-color: #31A357;
- padding: 10px;
- text-align: center;
- color: #E1E1E1;
- border-radius: 3px;
-}
-
-#controls-timeline-tooltip {
- background: #1F2021;
- border-radius: 3px;
- box-shadow: 0px 1px 2px rgba(0,0,0,.2);
- padding: 4px 8px;
- color: #fff;
- text-align: center;
- font-size: 11px;
- position: absolute;
- bottom: 53px;
- z-index: 100;
- opacity: 0;
- transition: opacity 0.25s;
-}
-
-#controls-timeline-tooltip:after {
- width: 0;
- height: 0;
- top: 100%;
- left: 50%;
- border-left: 6px solid transparent;
- border-right: 6px solid transparent;
- border-top: 6px solid #1F2021;
- content: "";
- margin-left: -6px;
- position: absolute;
-}
-
-#popup .button.bottom {
- position: absolute;
- bottom: 0;
- left: 0;
- right: 0;
-}
-
-#idle {
- position: absolute;
- top: 24px;
- bottom: 50px;
- left: 0;
- right: 0;
-}
-
-.hide-cursor {
- cursor: none;
-}
-
-.hide-cursor #overlay {
- opacity: 0 !important;
-}
-
-body:hover #overlay, body:hover .titlebar {
- opacity: 1;
-}
-
-.titlebar {
- background-color: #1F2021;
-}
-
-#controls {
- z-index: 11;
- position: absolute;
- left: 0;
- right: 0;
- bottom: 0;
- height: 50px;
- background-color: #1F2021;
- color: #727374;
-}
-
-#controls .center {
- margin-top: 13px;
-}
-
-#controls-timeline {
- background-color: #303233;
- height: 10px;
- width: 100%;
-}
-
-#controls-timeline-position {
- background-color: #31A357;
- width: 0%;
- height: 10px;
- transition: width 0.25s linear;
-}
-
-.controls-secondary {
- padding: 6px 10px 0 0;
-}
-
-#player-downloadspeed, #controls-playlist, #controls-chromecast {
- margin-right: 11px;
-}
-
-#controls-play {
- margin: 6px 9px 6px 14px;
-}
-
-#controls-play, #player-downloadspeed, #controls-fullscreen, #controls-playlist, #controls-chromecast {
- float: left;
-}
-
-#controls-play .mega-octicon, #player-downloadspeed .mega-octicon,
-#controls-fullscreen .mega-octicon, #controls-playlist .mega-octicon {
- /* this is the click buffer */
- padding: 3px 6px;
-}
-
-#controls-play span:hover .mega-octicon, #player-downloadspeed span:hover .mega-octicon,
-#controls-fullscreen span:hover .mega-octicon, #controls-playlist span:hover .mega-octicon,
-#controls-chromecast span:hover .mega-octicon {
- color: #31A357;
-}
-
-#controls-chromecast .chromecast {
- background-image: url('chromecast.png');
- background-size: 26px 72px;
- background-repeat: no-repeat;
- background-position: 0px 0px;
- margin-top: 6px;
- display: block;
- width: 26px;
- height: 18px;
-}
-#controls-chromecast .chromecast:hover, #controls-chromecast.selected .chromecast {
- background-position: 0px -18px;
-}
-.chromecasting #controls-chromecast .chromecast {
- background-position: 0px -36px;
-}
-.chromecasting #controls-chromecast .chromecast:hover, .chromecasting #controls-chromecast.selected .chromecast {
- background-position: 0px -54px;
-}
-
-#player-downloadspeed {
- margin-top: 4px;
- padding: 3px 20px;
-}
-
-#controls-playlist.selected .mega-octicon {
- color: #31A357;
-}
-
-.mega-octicon {
- color: #F0F0F0;
- font-size: 22px;
-}
-
-#controls-play .mega-octicon {
- color: #31A357;
-}
-
-#controls-time {
- width: 100px;
- float: left;
-}
-
-#controls-main {
- display: none;
-}
-
-#controls-time-current {
- color: #F0F0F0;
-}
-
-#controls-time-current, #controls-time-total {
- display: inline-block;
- min-width: 33px;
-}
-
-#controls-volume {
- margin: 11px 9px 11px 0;
- padding: 0;
- float: left;
-}
-
-#controls-volume-slider {
- -webkit-appearance: none;
- background: -webkit-gradient(linear, left top, right top, color-stop(50%, #31A357), color-stop(50%, #727374));
- width: 50px;
- height: 3px;
- border-radius: 0px;
-}
-
-#controls-volume-slider::-webkit-slider-thumb {
- -webkit-appearance: none;
- background-color: #31A357;
- opacity: 1.0;
- width: 7px;
- height: 7px;
- border-radius: 3.5px;
-}
-
-#controls-volume-slider:focus {
- outline: none;
-}
-
-#controls-name {
- float: left;
- margin-left: 20px;
-}
diff --git a/index.html b/index.html
deleted file mode 100644
index 5440a8a..0000000
--- a/index.html
+++ /dev/null
@@ -1,62 +0,0 @@
-
-
-
- playback
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ‒‒:‒‒
- /
- ‒‒:‒‒
-
-
-
-
-
-
-
-
-
diff --git a/index.js b/index.js
index 9faa2ed..4ddc61f 100644
--- a/index.js
+++ b/index.js
@@ -1,642 +1 @@
-var request = require('request')
-var drop = require('drag-and-drop-files')
-var mdns = require('multicast-dns')()
-var concat = require('concat-stream')
-var vtt = require('srt-to-vtt')
-var ipc = require('electron').ipcRenderer
-var remote = require('remote')
-var Menu = remote.require('menu')
-var MenuItem = remote.require('menu-item')
-var http = require('http')
-var rangeParser = require('range-parser')
-var pump = require('pump')
-var fs = require('fs')
-var eos = require('end-of-stream')
-var minimist = require('minimist')
-var JSONStream = require('JSONStream')
-var network = require('network-address')
-var chromecasts = require('chromecasts')()
-var $ = require('dombo')
-var titlebar = require('titlebar')()
-var clipboard = require('clipboard')
-var player = require('./player')
-var playlist = require('./playlist')
-var mouseidle = require('./mouseidle')
-
-var argv = minimist(JSON.parse(window.location.toString().split('#')[1]), {
- alias: {follow: 'f'},
- boolean: ['follow']
-})
-
-var printError = function (err) {
- if (err) console.log(err)
-}
-
-var onsubs = function (data) {
- media.subtitles(data)
-}
-
-ipc.on('add-to-playlist', function (links) {
- links.forEach(function (link) {
- if (/\.(vtt|srt)$/i.test(link)) {
- fs.createReadStream(link).pipe(vtt()).pipe(concat(onsubs))
- return
- }
-
- list.add(link, printError)
- })
-})
-
-$(document).on('paste', function (e) {
- ipc.emit('add-to-playlist', e.clipboardData.getData('text').split('\n'))
-})
-
-var media = player($('#player')[0])
-var list = playlist()
-
-if (process.platform !== 'win32') {
- titlebar.appendTo('#titlebar')
-}
-
-drop($('body')[0], function (files) {
- for (var i = 0; i < files.length; i++) {
- if (/\.(vtt|srt)$/i.test(files[i].path)) {
- fs.createReadStream(files[i].path).pipe(vtt()).pipe(concat(onsubs))
- return
- }
-
- list.add(files[i].path, printError)
- }
-})
-
-var videoDown = false
-var videoOffsets = [0, 0]
-
-$('#idle').on('mousedown', function (e) {
- videoDown = true
- videoOffsets = [e.clientX, e.clientY]
-})
-
-$('#idle').on('mouseup', function () {
- videoDown = false
-})
-
-$('#idle').on('mousemove', function (e) {
- if (videoDown) remote.getCurrentWindow().setPosition(e.screenX - videoOffsets[0], e.screenY - videoOffsets[1])
-})
-
-var onTop = false
-
-$(window).on('contextmenu', function (e) {
- e.preventDefault()
- videoDown = false
-
- var menu = new Menu()
-
- menu.append(new MenuItem({
- label: 'Always on top',
- type: 'checkbox',
- checked: onTop,
- click: function () {
- onTop = !onTop
- remote.getCurrentWindow().setAlwaysOnTop(onTop)
- }
- }))
-
- menu.append(new MenuItem({
- label: 'Paste link',
- click: function () {
- ipc.emit('add-to-playlist', clipboard.readText().split('\n'))
- }
- }))
-
- if (media.subtitles()) {
- menu.append(new MenuItem({
- label: 'Remove subtitles',
- click: function () {
- media.subtitles(null)
- }
- }))
- }
-
- menu.popup(remote.getCurrentWindow())
-})
-
-$('body').on('mouseover', function () {
- if (onTop) ipc.send('focus')
-})
-
-var isFullscreen = false
-
-var onfullscreentoggle = function (e) {
- if (!isFullscreen && e.shiftKey) {
- ipc.send('resize', {
- width: media.width,
- height: media.height,
- ratio: media.ratio
- })
- return
- }
-
- var $icon = $('#controls-fullscreen .mega-octicon')
- if (isFullscreen) {
- isFullscreen = false
- $('#titlebar')[0].style.display = 'block'
- $icon.removeClass('octicon-screen-normal')
- $icon.addClass('octicon-screen-full')
- ipc.send('exit-full-screen')
- } else {
- isFullscreen = true
- $('#titlebar')[0].style.display = 'none'
- $icon.removeClass('octicon-screen-full')
- $icon.addClass('octicon-screen-normal')
- ipc.send('enter-full-screen')
- }
-}
-
-var onplaytoggle = function () {
- if (media.playing) media.pause()
- else media.play()
-}
-
-$('#idle').on('dblclick', onfullscreentoggle)
-$('#controls-fullscreen').on('click', onfullscreentoggle)
-
-$('#controls-timeline').on('click', function (e) {
- var time = e.pageX / $('#controls-timeline')[0].offsetWidth * media.duration
- media.time(time)
-})
-
-function updateTimelineTooltip(e) {
- var tooltip = $('#controls-timeline-tooltip')[0]
- var percentage = e.pageX / $('#controls-timeline')[0].offsetWidth
- var time = formatTime(percentage * media.duration)
- tooltip.innerHTML = time
- tooltip.style.left = (e.pageX - tooltip.offsetWidth / 2) + "px"
-}
-
-$('#controls-timeline').on('mousemove', function (e) {
- updateTimelineTooltip(e)
-})
-
-$('#controls-timeline').on('mouseover', function (e) {
- var tooltip = $('#controls-timeline-tooltip')[0]
- tooltip.style.opacity = 1
- updateTimelineTooltip(e)
-})
-
-$('#controls-timeline').on('mouseout', function (e) {
- var tooltip = $('#controls-timeline-tooltip')[0]
- tooltip.style.opacity = 0
-})
-
-var isVolumeSliderClicked = false
-
-function updateAudioVolume(value) {
- media.volume(value)
-}
-
-function updateVolumeSlider(volume) {
- var val = volume.value * 100
- volume.style.background = '-webkit-gradient(linear, left top, right top, color-stop(' + val.toString() + '%, #31A357), color-stop(' + val.toString() + '%, #727374))'
-}
-
-$('#controls-volume-slider').on('mousemove', function (e) {
- if (isVolumeSliderClicked) {
- var volume = $('#controls-volume-slider')[0]
- updateAudioVolume(volume.value)
- updateVolumeSlider(volume)
- }
-})
-
-$('#controls-volume-slider').on('mousedown', function (e) {
- isVolumeSliderClicked = true
-})
-
-$('#controls-volume-slider').on('mouseup', function (e) {
- var volume = $('#controls-volume-slider')[0]
- updateAudioVolume(volume.value)
- updateVolumeSlider(volume)
- isVolumeSliderClicked = false
-})
-
-$(document).on('keydown', function (e) {
- if (e.keyCode === 27 && isFullscreen) return onfullscreentoggle(e)
- if (e.keyCode === 13 && e.metaKey) return onfullscreentoggle(e)
- if (e.keyCode === 13 && e.shiftKey) return onfullscreentoggle(e)
- if (e.keyCode === 32) return onplaytoggle(e)
-
- if ($('#controls-playlist').hasClass('selected')) $('#controls-playlist').trigger('click')
- if ($('#controls-chromecast').hasClass('selected')) $('#controls-chromecast').trigger('click')
-})
-
-mouseidle($('#idle')[0], 3000, 'hide-cursor')
-
-list.on('select', function () {
- $('#controls-name')[0].innerText = list.selected.name
- media.play('http://127.0.0.1:' + server.address().port + '/' + list.selected.id)
- if (list.selected.subtitles) onsubs(list.selected.subtitles)
- updatePlaylist()
-})
-
-var updatePlaylist = function () {
- var html = ''
-
- list.entries.forEach(function (entry, i) {
- html += '' +
- '' + entry.name + '
'
- })
-
- $('#playlist-entries')[0].innerHTML = html
-}
-
-var updateChromecast = function () {
- var html = ''
-
- chromecasts.players.forEach(function (player, i) {
- html += '' +
- '' + player.name + ''
- })
-
- $('#chromecast-entries')[0].innerHTML = html
-}
-
-chromecasts.on('update', updateChromecast)
-
-var updateSpeeds = function () {
- $('#player-downloadspeed')[0].innerText = ''
- list.entries.forEach(function (entry, i) {
- if (!entry.downloadSpeed) return
-
- $('.playlist-entry[data-index="' + i + '"] .status').addClass('octicon-sync')
-
- var kilobytes = entry.downloadSpeed() / 1024
- var megabytes = kilobytes / 1024
- var text = megabytes > 1 ? megabytes.toFixed(1) + ' mb/s' : Math.floor(kilobytes) + ' kb/s'
-
- if (list.selected === entry) $('#player-downloadspeed')[0].innerText = text
- })
-}
-setInterval(updateSpeeds, 750)
-
-list.on('update', updatePlaylist)
-
-list.once('update', function () {
- list.select(0)
-})
-
-var popupSelected = function () {
- return $('#controls-playlist').hasClass('selected') || $('#controls-chromecast').hasClass('selected')
-}
-
-var closePopup = function (e) {
- if (e && (e.target === $('#controls-playlist .mega-octicon')[0] || e.target === $('#controls-chromecast .chromecast')[0])) return
- $('#popup')[0].style.opacity = 0
- $('#controls-playlist').removeClass('selected')
- $('#controls-chromecast').removeClass('selected')
-}
-
-$('#controls').on('click', closePopup)
-$('#idle').on('click', closePopup)
-
-$('#playlist-entries').on('click', '.playlist-entry', function (e) {
- var id = Number(this.getAttribute('data-id'))
- list.select(id)
-})
-
-$('#chromecast-entries').on('click', '.chromecast-entry', function (e) {
- var id = Number(this.getAttribute('data-id'))
- var player = chromecasts.players[id]
-
- if (media.casting === player) {
- $('body').removeClass('chromecasting')
- media.chromecast(null)
- return updateChromecast()
- }
-
- $('body').addClass('chromecasting')
- media.chromecast(player)
- updateChromecast()
-})
-
-var updatePopup = function () {
- if (popupSelected()) {
- $('#popup')[0].style.display = 'block'
- $('#popup')[0].style.opacity = 1
- } else {
- $('#popup')[0].style.opacity = 0
- }
-}
-
-$('#controls-chromecast').on('click', function (e) {
- if ($('#controls-chromecast').hasClass('selected')) {
- closePopup()
- return
- }
-
- $('#popup')[0].className = 'chromecast'
- $('#controls .controls-secondary .selected').removeClass('selected')
- $('#controls-chromecast').addClass('selected')
- chromecasts.update()
- updatePopup()
-})
-
-$('#controls-playlist').on('click', function (e) {
- if ($('#controls-playlist').hasClass('selected')) {
- closePopup()
- return
- }
-
- $('#popup')[0].className = 'playlist'
- $('#controls .controls-secondary .selected').removeClass('selected')
- $('#controls-playlist').addClass('selected')
- updatePopup()
-})
-
-$('#playlist-add-media').on('click', function () {
- ipc.send('open-file-dialog')
-})
-
-$('#popup').on('transitionend', function () {
- if (!popupSelected()) $('#popup')[0].style.display = 'none'
-})
-
-titlebar.on('close', function () {
- ipc.send('close')
-})
-
-titlebar.on('minimize', function () {
- ipc.send('minimize')
-})
-
-titlebar.on('maximize', function () {
- ipc.send('maximize')
-})
-
-titlebar.on('fullscreen', onfullscreentoggle)
-
-var appmenu_template = [
- {
- label: 'Playback',
- submenu: [
- {
- label: 'About Playback',
- click: function() { ipc.send('open-url-in-external', 'https://mafintosh.github.io/playback/') }
- },
- {
- type: 'separator'
- },
- {
- label: 'Quit',
- accelerator: 'Command+Q',
- click: function() { ipc.send('close') }
- }
- ]
- },
- {
- label: 'File',
- submenu: [
- {
- label: 'Add media',
- accelerator: 'Command+O',
- click: function() { ipc.send('open-file-dialog') }
- },
- {
- label: 'Add link from clipboard',
- accelerator: 'CommandOrControl+V',
- click: function () { ipc.emit('add-to-playlist', clipboard.readText().split('\n')) }
- }
- ]
- },
- {
- label: 'Window',
- submenu: [
- {
- label: 'Minimize',
- accelerator: 'Command+M',
- click: function() { ipc.send('minimize') }
- },
- {
- label: 'Toggle Full Screen',
- accelerator: 'Command+Enter',
- click: onfullscreentoggle
- }
- ]
- },
- {
- label: 'Help',
- submenu: [
- {
- label: 'Report Issue',
- click: function() { ipc.send('open-url-in-external', 'https://github.com/mafintosh/playback/issues') }
- },
- {
- label: 'View Source Code on GitHub',
- click: function() { ipc.send('open-url-in-external', 'https://github.com/mafintosh/playback') }
- },
- {
- type: 'separator'
- },
- {
- label: 'Releases',
- click: function() { ipc.send('open-url-in-external', 'https://github.com/mafintosh/playback/releases') }
- }
- ]
- }
-]
-var appmenu = Menu.buildFromTemplate(appmenu_template)
-Menu.setApplicationMenu(appmenu)
-
-var formatTime = function (secs) {
- var hours = (secs / 3600) | 0
- var mins = ((secs - hours * 3600) / 60) | 0
- secs = (secs - (3600 * hours + 60 * mins)) | 0
- if (mins < 10) mins = '0' + mins
- if (secs < 10) secs = '0' + secs
- return (hours ? hours + ':' : '') + mins + ':' + secs
-}
-
-var updateInterval
-media.on('metadata', function () {
- // TODO: comment in again when not quirky
- // if (!isFullscreen) {
- // ipc.send('resize', {
- // width: media.width,
- // height: media.height,
- // ratio: media.ratio
- // })
- // }
-
- $('#controls-main')[0].style.display = 'block'
- $('#controls-time-total')[0].innerText = formatTime(media.duration)
- $('#controls-time-current')[0].innerText = formatTime(media.time())
-
- clearInterval(updateInterval)
- updateInterval = setInterval(function () {
- $('#controls-timeline-position')[0].style.width = (100 * (media.time() / media.duration)) + '%'
- $('#controls-time-current')[0].innerText = formatTime(media.time())
- }, 250)
-})
-
-$('#controls-play').on('click', onplaytoggle)
-
-media.on('end', function () {
- ipc.send('allow-sleep')
- list.selectNext()
-})
-
-media.on('play', function () {
- ipc.send('prevent-sleep')
- $('#splash').toggleClass('hidden', !media.casting)
- $('#player').toggleClass('hidden', media.casting)
- $('#controls-play .octicon-playback-play').removeClass('octicon-playback-play')
- $('#controls-play .mega-octicon').addClass('octicon-playback-pause')
-})
-
-media.on('pause', function () {
- ipc.send('allow-sleep')
- $('#controls-play .octicon-playback-pause').removeClass('octicon-playback-pause')
- $('#controls-play .mega-octicon').addClass('octicon-playback-play')
-})
-
-var server = http.createServer(function (req, res) {
- if (req.headers.origin) res.setHeader('Access-Control-Allow-Origin', req.headers.origin)
-
- if (req.url === '/subtitles') {
- var buf = media.subtitles()
-
- if (buf) {
- res.setHeader('Content-Type', 'text/vtt; charset=utf-8')
- res.setHeader('Content-Length', buf.length)
- res.end(buf)
- } else {
- res.statusCode = 404
- res.end()
- }
- }
-
- if (req.url === '/follow') { // TODO: do not hardcode /0
- if (!list.selected) return res.end()
- var stringify = JSONStream.stringify()
-
- var onseek = function () {
- stringify.write({type: 'seek', time: media.time() })
- }
-
- var onsubs = function (data) {
- stringify.write({type: 'subtitles', data: data.toString('base64')})
- }
-
- stringify.pipe(res)
- stringify.write({type: 'open', url: 'http://' + network() + ':' + server.address().port + '/' + list.selected.id, time: media.time() })
-
- media.on('subtitles', onsubs)
- media.on('seek', onseek)
- eos(res, function () {
- media.removeListener('subtitles', onsubs)
- media.removeListener('seek', onseek)
- })
- return
- }
-
- var id = Number(req.url.slice(1))
- var file = list.get(id)
-
- if (!file) {
- res.statusCode = 404
- res.end()
- return
- }
-
- var range = req.headers.range && rangeParser(file.length, req.headers.range)[0]
-
- res.setHeader('Accept-Ranges', 'bytes')
- res.setHeader('Content-Type', 'video/mp4')
-
- if (!range) {
- res.setHeader('Content-Length', file.length)
- if (req.method === 'HEAD') return res.end()
- pump(file.createReadStream(), res)
- return
- }
-
- res.statusCode = 206
- res.setHeader('Content-Length', range.end - range.start + 1)
- res.setHeader('Content-Range', 'bytes ' + range.start + '-' + range.end + '/' + file.length)
- if (req.method === 'HEAD') return res.end()
- pump(file.createReadStream(range), res)
-})
-
-server.listen(0, function () {
- console.log('Playback server running on port ' + server.address().port)
-
- argv._.forEach(function (file) {
- if (file) list.add(file, printError)
- })
-
- if (argv.follow) {
- mdns.on('response', function onresponse(response) {
- response.answers.forEach(function (a) {
- if (a.name !== 'playback') return
- clearInterval(interval)
- mdns.removeListener('response', onresponse)
-
- var host = a.data.target + ':' + a.data.port
-
- request('http://' + host + '/follow').pipe(JSONStream.parse('*')).on('data', function (data) {
- if (data.type === 'open') {
- media.play(data.url)
- media.time(data.time)
- }
-
- if (data.type === 'seek') {
- media.time(data.time)
- }
-
- if (data.type === 'subtitles') {
- media.subtitles(data.data)
- }
- })
- })
- })
-
- var query = function () {
- mdns.query({
- questions: [{
- name: 'playback',
- type: 'SRV'
- }]
- })
- }
-
- var interval = setInterval(query, 5000)
- query()
- } else {
- mdns.on('query', function (query) {
- var valid = query.questions.some(function (q) {
- return q.name === 'playback'
- })
-
- if (!valid) return
-
- mdns.respond({
- answers: [{
- type: 'SRV',
- ttl: 5,
- name: 'playback',
- data: {port: server.address().port, target: network()}
- }]
- })
- })
- }
-
- setTimeout(function () {
- ipc.send('ready')
- }, 10)
-})
-
-media.volume(0.5)
-$('#controls-volume-slider')[0].setAttribute("value", 0.5)
-$('#controls-volume-slider')[0].setAttribute("min", 0)
-$('#controls-volume-slider')[0].setAttribute("max", 1)
-$('#controls-volume-slider')[0].setAttribute("step", 0.05)
+require('./app/app.js')
diff --git a/mouseidle.js b/mouseidle.js
deleted file mode 100644
index 3060038..0000000
--- a/mouseidle.js
+++ /dev/null
@@ -1,50 +0,0 @@
-var $ = require('dombo')
-
-module.exports = function (elem, timeout, className) {
- var max = (timeout / 250) | 0
- var overMovie = false
- var hiding = false
- var moving = 0
- var tick = 0
- var mousedown = false
-
- var update = function () {
- if (hiding) {
- $('body').removeClass(className)
- hiding = false
- }
- }
-
- $(elem).on('mouseover', function () {
- overMovie = true
- update()
- })
-
- $(elem).on('mouseout', function () {
- overMovie = false
- })
-
- $(elem).on('mousedown', function (e) {
- mousedown = true
- moving = tick
- update()
- })
-
- $(elem).on('mouseup', function (e) {
- mousedown = false
- moving = tick
- })
-
- $(window).on('mousemove', function (e) {
- moving = tick
- update()
- })
-
- setInterval(function () {
- tick++
- if (!overMovie) return
- if (tick - moving < max || mousedown) return
- hiding = true
- $('body').addClass(className)
- }, 250)
-}
diff --git a/package.json b/package.json
index 0145bc8..70c5c71 100644
--- a/package.json
+++ b/package.json
@@ -2,43 +2,55 @@
"name": "playback",
"version": "1.6.0",
"description": "Video player build using atom-shell and node.js",
- "main": "app.js",
+ "main": "index.js",
"dependencies": {
"JSONStream": "^0.10.0",
- "chromecasts": "^1.2.1",
+ "babel-core": "^6.4.5",
+ "babel-preset-react": "^6.3.13",
+ "babel-register": "^6.4.3",
+ "chromecasts": "^1.8.0",
"concat-stream": "^1.4.7",
- "dombo": "^3.2.0",
"drag-and-drop-files": "0.0.1",
"duplexify": "^3.2.0",
"end-of-stream": "^1.1.0",
- "filereader-stream": "^0.2.0",
+ "lodash.debounce": "^3.1.1",
"minimist": "^1.1.1",
- "multicast-dns": "^2.0.0",
+ "multicast-dns": "^5.4.0",
"network-address": "^1.0.0",
- "octicons": "2.4.1",
+ "node-uuid": "^1.4.7",
"pump": "^1.0.0",
+ "q-io": "^1.13.2",
"range-parser": "^1.0.2",
+ "react": "^0.14.3",
+ "react-addons-css-transition-group": "^0.14.3",
+ "react-addons-update": "^0.14.3",
+ "react-dom": "^0.14.3",
+ "react-slider": "^0.6.0",
"request": "^2.54.0",
- "roboto-fontface": "^0.4.2",
"srt-to-vtt": "^1.0.2",
- "titlebar": "^1.1.0",
"webtorrent": "^0.63.3",
"ytdl-core": "^0.5.1"
},
"devDependencies": {
- "electron-packager": "^5.1.1",
- "electron-prebuilt": "0.35.4"
+ "electron-packager": "^5.2.1",
+ "electron-prebuilt": "0.36.7",
+ "eslint": "^2.3.0",
+ "eslint-config-standard": "^5.1.0",
+ "eslint-plugin-react": "^4.2.1",
+ "node-sass": "^3.4.2"
},
"bin": {
- "playback": "./app.js"
+ "playback": "./index.js"
},
"scripts": {
- "rebuild": "npm rebuild --runtime=electron --target=0.35.4 --disturl=https://atom.io/download/atom-shell",
- "start": "electron app.js",
- "dev": "electron app.js test.mp4",
- "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",
- "win-bundle": "electron-packager . Playback --platform=win32 --arch=ia32 --version=0.35.4 --icon=icon.ico",
- "linux-64-bundle": "electron-packager . Playback --platform=linux --arch=x64 --version=0.35.4 ignore='node_modules/(electron-packager|electron-prebuilt)'"
+ "rebuild": "npm rebuild --runtime=electron --target=0.36.7 --disturl=https://atom.io/download/atom-shell",
+ "start": "electron index.js",
+ "lint": "eslint app/** --ext .js,.jsx",
+ "sass": "node-sass app/front/styles/main.scss app/front/styles/main.css",
+ "dev": "npm run lint && npm run sass && npm run start",
+ "mac-bundle": "electron-packager . Playback --platform=darwin --arch=x64 --version=0.36.7 --ignore='node_modules/(electron-packager|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",
+ "win-bundle": "electron-packager . Playback --platform=win32 --arch=ia32 --version=0.36.7 --ignore='node_modules/(electron-packager|electron-prebuilt)' --icon=icon.ico",
+ "linux-64-bundle": "electron-packager . Playback --platform=linux --arch=x64 --version=0.36.7 --ignore='node_modules/(electron-packager|electron-prebuilt)'"
},
"repository": {
"type": "git",
diff --git a/player.js b/player.js
deleted file mode 100644
index 0aa728c..0000000
--- a/player.js
+++ /dev/null
@@ -1,180 +0,0 @@
-var events = require('events')
-var network = require('network-address')
-
-module.exports = function ($video) {
- var that = new events.EventEmitter()
- var atEnd = false
- var lastUrl = null
-
- that.setMaxListeners(0)
-
- that.width = 0
- that.height = 0
- that.element = $video
-
- var chromecast = null
- var chromecastTime = 0
- var chromecastOffset = 0
- var chromecastSubtitles = 1
- var interval = null
-
- var onerror = function () {
- if (chromecast) chromecast.removeListener('error', onerror)
- that.chromecast(null)
- }
-
- var onmetadata = function (err, status) {
- if (err) return onerror(err)
- if (chromecastTime) chromecastOffset = 0
- chromecastTime = status.currentTime
- chromecastSubtitles = 1
- that.duration = status.media.duration
- that.emit('metadata')
-
- clearInterval(interval)
- interval = setInterval(function () {
- chromecast.status(function (err, status) {
- if (err) return onerror(err)
-
- if (!status) {
- chromecastOffset = 0
- clearInterval(interval)
- atEnd = true
- that.playing = false
- that.emit('pause')
- that.emit('end')
- return
- }
-
- if (chromecastTime) chromecastOffset = 0
- chromecastTime = status.currentTime
- })
- }, 1000)
- }
-
- that.casting = false
- that.chromecast = function (player) {
- chromecastOffset = chromecast ? 0 : $video.currentTime
- clearInterval(interval)
- if (chromecast && that.playing) chromecast.stop()
- chromecast = player
- that.casting = player
- if (chromecast) chromecast.on('error', onerror)
- if (!that.playing) return
- media.play(lastUrl, that.casting ? chromecastOffset : chromecastTime)
- }
-
- $video.addEventListener('seeked', function () {
- if (chromecast) return
- that.emit('seek')
- }, false)
-
- $video.addEventListener('ended', function () {
- if (chromecast) return
- atEnd = true
- that.playing = false
- that.emit('pause')
- that.emit('end')
- }, false)
-
- $video.addEventListener('loadedmetadata', function () {
- if (chromecast) return
- that.width = $video.videoWidth
- that.height = $video.videoHeight
- that.ratio = that.width / that.height
- that.duration = $video.duration
- that.emit('metadata')
- }, false)
-
- that.time = function (time) {
- atEnd = false
- if (chromecast) {
- if (arguments.length) {
- chromecastOffset = 0
- chromecast.seek(time)
- }
- return chromecastOffset || chromecastTime
- }
- if (arguments.length) $video.currentTime = time
- return $video.currentTime
- }
-
- that.playing = false
-
- that.play = function (url, time) {
- if (!url && !lastUrl) return
- var changed = url && lastUrl !== url
- if (changed) subs = null
- if (chromecast) {
- $video.innerHTML = '' // clear
- $video.pause()
- $video.load()
- if (url) lastUrl = url
- else url = lastUrl
- atEnd = false
- if (url) {
- var mediaUrl = url.replace('127.0.0.1', network())
- var subsUrl = mediaUrl.replace(/(:\/\/.+)\/.*/, '$1/subtitles')
- var subsList = []
- for (var i = 0; i < 100; i++) subsList.push(subsUrl)
- chromecast.play(mediaUrl, {title: 'Playback', seek: time || 0, subtitles: subsList, autoSubtitles: !!subs }, onmetadata)
- } else {
- chromecast.resume()
- }
- } else {
- if (atEnd && url === lastUrl) $video.time(0)
- if (!url) {
- $video.play()
- } else {
- lastUrl = url
- atEnd = false
- $video.innerHTML = '' // clear
- var $src = document.createElement('source')
- $src.setAttribute('src', url)
- $src.setAttribute('type', 'video/mp4')
- $video.appendChild($src)
- if (changed) $video.load()
- $video.play()
- if (time) $video.currentTime = time
- }
- }
- that.playing = true
- that.emit('play')
- }
-
- that.pause = function () {
- if (chromecast) chromecast.pause()
- else $video.pause()
- that.playing = false
- that.emit('pause')
- }
-
- var subs = null
- that.subtitles = function (buf) {
- if (!arguments.length) return subs
- subs = buf
-
- if (chromecast) {
- if (!buf) chromecast.subtitles(false)
- else chromecast.subtitles(++chromecastSubtitles)
- return
- }
-
- if ($video.querySelector('track')) $video.removeChild($video.querySelector('track'))
- if (!buf) return null
- var $track = document.createElement('track')
- $track.setAttribute('default', 'default')
- $track.setAttribute('src', 'data:text/vtt;base64,'+buf.toString('base64'))
- $track.setAttribute('label', 'Subtitles')
- $track.setAttribute('kind', 'subtitles')
- $video.appendChild($track)
- that.emit('subtitles', buf)
- return buf
- }
-
- that.volume = function (value) {
- $video.volume = value
- }
-
- return that
-}
diff --git a/playlist.js b/playlist.js
deleted file mode 100644
index 03174e1..0000000
--- a/playlist.js
+++ /dev/null
@@ -1,263 +0,0 @@
-var torrents = require('webtorrent')
-var request = require('request')
-var duplex = require('duplexify')
-var ytdl = require('ytdl-core')
-var events = require('events')
-var path = require('path')
-var fs = require('fs')
-var vtt = require('srt-to-vtt')
-var concat = require('concat-stream')
-
-var noop = function () {}
-
-module.exports = function () {
- var that = new events.EventEmitter()
-
- that.entries = []
-
- var onmagnet = function (link, cb) {
- console.log('torrent ' + link)
-
- var engine = torrents()
- var subtitles = {}
-
- engine.add(link, {
- announce: [ 'wss://tracker.webtorrent.io' ]
- }, function (torrent) {
- console.log('torrent ready')
-
- torrent.files.forEach(function (f) {
- if (/\.(vtt|srt)$/i.test(f.name)) {
- subtitles[f.name] = f;
- }
- })
-
- torrent.files.forEach(function (f) {
- f.downloadSpeed = torrent.downloadSpeed()
- if (/\.(mp4|mkv|mp3)$/i.test(f.name)) {
- f.select()
- f.id = that.entries.push(f) - 1
-
- var basename = f.name.substr(0, f.name.lastIndexOf('.'))
- var subtitle = subtitles[basename + '.srt'] || subtitles[basename + '.vtt']
- if (subtitle) {
- subtitle.createReadStream().pipe(vtt()).pipe(concat(function(data) {
- f.subtitles = data
- }))
- }
- }
-
- })
-
- setInterval(function () {
- console.log(torrent.downloadSpeed() + ' (' + torrent.swarm.wires.length + ')')
- }, 1000)
-
- that.emit('update')
- cb()
- })
- }
-
- var ontorrent = function (link, cb) {
- fs.readFile(link, function (err, buf) {
- if (err) return cb(err)
- onmagnet(buf, cb)
- })
- }
-
- var onyoutube = function (link, cb) {
- var file = {}
- var url = /https?:/.test(link) ? link : 'https:' + link
-
- getYoutubeData(function (err, data) {
- if (err) return cb(err)
- var fmt = data.fmt
- var info = data.info
- request({method: 'HEAD', url: fmt.url}, function (err, resp, body) {
- if (err) return cb(err)
- var len = resp.headers['content-length']
- if (!len) return cb(new Error('no content-length on response'))
- file.length = +len
- file.name = info.title
-
- file.createReadStream = function (opts) {
- if (!opts) opts = {}
- // fetch this for every range request
- // TODO try and avoid doing this call twice the first time
- getYoutubeData(function (err, data) {
- if (err) return cb(err)
- var vidUrl = data.fmt.url
- if (opts.start || opts.end) vidUrl += '&range=' + ([opts.start || 0, opts.end || len].join('-'))
- stream.setReadable(request(vidUrl))
- })
-
- var stream = duplex()
- return stream
- }
- file.id = that.entries.push(file) - 1
- that.emit('update')
- cb()
- })
- })
-
- function getYoutubeData (cb) {
- ytdl.getInfo(url, function (err, info) {
- if (err) return cb(err)
-
- var vidFmt
- var formats = info.formats
-
- formats.sort(function sort (a, b) {
- return +a.itag - +b.itag
- })
-
- var vidFmt
- formats.forEach(function (fmt) {
- // prefer webm
- if (fmt.itag === '46') return vidFmt = fmt
- if (fmt.itag === '45') return vidFmt = fmt
- if (fmt.itag === '44') return vidFmt = fmt
- if (fmt.itag === '43') return vidFmt = fmt
-
- // otherwise h264
- if (fmt.itag === '38') return vidFmt = fmt
- if (fmt.itag === '37') return vidFmt = fmt
- if (fmt.itag === '22') return vidFmt = fmt
- if (fmt.itag === '18') return vidFmt = fmt
- })
-
- if (!vidFmt) return cb (new Error('No suitable video format found'))
-
- cb(null, {info: info, fmt: vidFmt})
- })
- }
- }
-
- var onfile = function (link, cb) {
- var file = {}
-
- fs.stat(link, function (err, st) {
- if (err) return cb(err)
-
- file.length = st.size
- file.name = path.basename(link)
- file.createReadStream = function (opts) {
- return fs.createReadStream(link, opts)
- }
-
- file.id = that.entries.push(file) - 1
-
- var ondone = function () {
- that.emit('update')
- cb()
- }
- var basename = link.substr(0, link.lastIndexOf('.'))
- var extensions = ['srt', 'vtt']
- var next = function () {
- var ext = extensions.shift()
- if (!ext) return ondone()
-
- fs.exists(basename + '.' + ext, function(exists) {
- if (!exists) return next()
- fs.createReadStream(basename + '.' + ext).pipe(vtt()).pipe(concat(function(data) {
- file.subtitles = data
- ondone()
- }))
- })
- }
- next()
- })
- }
-
- var onhttplink = function (link, cb) {
- var file = {}
-
- file.name = link.lastIndexOf('/') > -1 ? link.split('/').pop() : link
-
- file.createReadStream = function (opts) {
- if (!opts) opts = {}
-
- if (opts && (opts.start || opts.end)) {
- var rs = 'bytes=' + (opts.start || 0) + '-' + (opts.end || file.length || '')
- return request(link, {headers: {Range: rs}})
- }
-
- return request(link)
- }
-
- // first, get the head for the content length.
- // IMPORTANT: servers without HEAD will not work.
- request.head(link, function (err, response) {
- if (err) return cb(err)
- if (!/2\d\d/.test(response.statusCode)) return cb(new Error('request failed'))
-
- file.length = Number(response.headers['content-length'])
- file.id = that.entries.push(file) - 1
- that.emit('update')
- cb()
- })
- }
-
- var onipfslink = function (link, cb) {
- if (link[0] != '/') link = "/" + link // / may be stripped in add
-
- var local = 'localhost:8080' // todo: make this configurable
- var gateway = 'gateway.ipfs.io'
- var file = {}
-
- // first, try the local http gateway
- var u = 'http://' + local + link
- console.log('trying local ipfs gateway: ' + u)
- onhttplink(u, function (err) {
- if (!err) return cb() // done.
-
- // error? ok try fuse... maybe the gateway's broken.
- console.log('trying mounted ipfs fs (just in case)')
- onfile(link, function (err) {
- if (!err) return cb() // done.
-
- // worst case, try global ipfs gateway.
- var u = 'http://' + gateway + link
- console.log('trying local ipfs gateway: ' + u)
- onhttplink(u, cb)
- })
- })
- }
-
- that.selected = null
-
- that.deselect = function () {
- that.selected = null
- that.emit('deselect')
- }
-
- that.selectNext = function () {
- if (!that.entries.length) return null
- if (!that.selected) return that.select(0)
- if (that.selected.id === that.entries.length - 1) return null
- return that.select(that.selected.id + 1)
- }
-
- that.select = function (id) {
- that.selected = that.get(id)
- that.emit('select')
- return that.selected
- }
-
- that.get = function (id) {
- return that.entries[id]
- }
-
- that.add = function (link, cb) {
- link = link.replace('playback://', '').replace('playback:', '') // strip playback protocol
- if (!cb) cb = noop
- if (/magnet:/.test(link)) return onmagnet(link, cb)
- if (/\.torrent$/i.test(link)) return ontorrent(link, cb)
- if (/youtube\.com\/watch|youtu.be/i.test(link)) return onyoutube(link, cb)
- if (/^\/*(ipfs|ipns)\//i.test(link)) return onipfslink(link, cb)
- if (/^https?:\/\//i.test(link)) return onhttplink(link, cb)
- onfile(link, cb)
- }
-
- return that
-}