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 = ( +
    +
    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 ( + + {this._renderGraphic()} + + ) + } +}) 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 -}