diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 375c9eca..00000000 --- a/.babelrc +++ /dev/null @@ -1,19 +0,0 @@ -{ - "presets": ["react-native"], - "plugins": [ - "transform-object-rest-spread", - "react-hot-loader/babel", [ - "module-resolver", { - "root": ["./"], - "alias": { - "@views": "./src/views", - "@pages": "./src/views/pages", - "@core": "./src/core", - "@components": "./src/views/components", - "@styles": "./src/styles", - "@layouts": "./src/views/layouts", - "@config": "./config/" - } - }] - ] -} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000..dee2d47f --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,43 @@ +name: Publish + +on: + push: + branches: + - master + +jobs: + publish: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [macos-latest] + + steps: + - name: Checkout git repo + uses: actions/checkout@v1 + + - name: Install Node, NPM and Yarn + uses: actions/setup-node@v1 + with: + node-version: 12.13.1 + + - name: Install dependencies + run: | + yarn install + + - name: Postinstall + run: | + yarn postinstall + + - name: Publish releases + uses: samuelmeuli/action-electron-builder@v1 + with: + # GitHub token, automatically provided to the action + # (No need to define this secret in the repo settings) + github_token: ${{ secrets.github_token }} + mac_certs: ${{ secrets.mac_certs }} + mac_certs_password: ${{ secrets.mac_certs_password }} + + - name: Show release/ + run: du -sh release/ && ls -l release/ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..b51e3eaa --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,35 @@ +name: Test + +on: push + +jobs: + release: + runs-on: ${{ matrix.os }} + + strategy: + matrix: + os: [macos-latest, windows-latest, ubuntu-latest] + + steps: + - name: Check out Git repository + uses: actions/checkout@v1 + + - name: Install Node.js, NPM and Yarn + uses: actions/setup-node@v1 + with: + node-version: 12.13.1 + + - name: yarn install + run: | + yarn install --frozen-lockfile --network-timeout 300000 + + - name: yarn test + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + yarn package + yarn lint + +# Failing beacuse virtual framebuffer not installed +# yarn build-e2e +# yarn test-e2e diff --git a/.gitignore b/.gitignore index eb446135..c24b37f2 100644 --- a/.gitignore +++ b/.gitignore @@ -43,5 +43,17 @@ dist coverage/ ios/main.jsbundle ios/main.jsbundle.meta -orbitdb -nodejs-assets/build-native-modules-MacOS-helper-script-* \ No newline at end of file +nodejs-assets/build-native-modules-MacOS-helper-script-* +nodejs-assets/nodejs-project/bundle.js.tmp* +builds/ + +# Electron packaged +release +app/background.prod.js +app/background.prod.js.map +app/main.prod.js +app/main.prod.js.map +app/renderer.prod.js +app/renderer.prod.js.map +dist +dll diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 00000000..02b1010b --- /dev/null +++ b/.yarnrc @@ -0,0 +1 @@ +network-timeout 500000 diff --git a/Gruntfile.js b/Gruntfile.js deleted file mode 100644 index a313e858..00000000 --- a/Gruntfile.js +++ /dev/null @@ -1,157 +0,0 @@ -'use strict' - -var serveStatic = require('serve-static') - -var mountFolder = function (dir) { - return serveStatic(require('path').resolve(dir)) -} - -var webpackDistConfig = require('./config/webpack.dist.config.js') -var webpackDevConfig = require('./config/webpack.config.js') - -module.exports = function (grunt) { - // Let *load-grunt-tasks* require everything - require('load-grunt-tasks')(grunt) - - // Read configuration from package.json - var pkgConfig = grunt.file.readJSON('package.json') - - grunt.initConfig({ - 'pkg': pkgConfig, - - 'webpack': { - options: webpackDistConfig, - dist: { - cache: false - } - }, - - 'webpack-dev-server': { - options: { - hot: true, - port: 8000, - webpack: webpackDevConfig, - publicPath: '/assets/', - contentBase: './<%= pkg.src %>/', - historyApiFallback: { - index: 'index.web.html' - } - }, - - start: { - - } - }, - - 'connect': { - options: { - port: 8000 - }, - - dist: { - options: { - keepalive: true, - middleware: function () { - return [ - mountFolder(pkgConfig.dist) - ] - } - } - } - }, - - 'open': { - options: { - delay: 500 - }, - dev: { - path: 'http://localhost:<%= connect.options.port %>/' - }, - dist: { - path: 'http://localhost:<%= connect.options.port %>/' - } - }, - - 'karma': { - unit: { - configFile: 'karma.conf.js' - } - }, - - 'copy': { - dist: { - files: [ - { - flatten: true, - src: ['<%= pkg.src %>/index.web.html'], - dest: '<%= pkg.dist %>/index.html' - }, - { - flatten: true, - src: ['<%= pkg.src %>/favicon.ico'], - dest: '<%= pkg.dist %>/favicon.ico' - } - ] - } - }, - - 'clean': { - dist: { - options: { - force: true - }, - files: [{ - dot: true, - src: [ - '<%= pkg.dist %>' - ] - }] - } - }, - - 'exec': { - launch_electron: 'NODE_ENV=development electron electron.js --inspect', - launch_electron_dist: 'NODE_ENV=production electron electron.js' - }, - - 'concurrent': { - electron: { - tasks: ['webpack-dev-server', 'exec:launch_electron'], - options: { - logConcurrentOutput: true - } - }, - electron_dist: { - task: ['webpack', 'exec:launch_electron_dist'], - options: { - logConcurrentOutput: true - } - } - } - }) - - grunt.registerTask('serve-web', function (target) { - if (target === 'dist') { - return grunt.task.run(['build', 'open:dist', 'connect:dist']) - } - - grunt.task.run([ - 'open:dev', - 'webpack-dev-server' - ]) - }) - - grunt.registerTask('serve-electron', function (target) { - if (target === 'dist') { - return grunt.task.run(['webpack', 'exec:launch_electron_dist']) - } - - grunt.task.run([ - 'concurrent:electron' - ]) - }) - - grunt.registerTask('test', ['karma']) - grunt.registerTask('build', ['clean', 'copy', 'webpack']) - grunt.registerTask('default', []) -} diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..b442934b --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE.md b/NOTICE.md index 8329ba9f..d8503890 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -1,4 +1,4 @@ -Much of this project adapted from Richard Park - [Soundcloud Redux](https://github.com/r-park/soundcloud-redux) +Much of this repo adapted from Richard Park - [Soundcloud Redux](https://github.com/r-park/soundcloud-redux) Copyright (c) 2016 Richard Park @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/README.md b/README.md index f223ef74..71d00649 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,86 @@ + + Record Logo + + # Record App [![MIT License](http://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) [![standard-readme compliant](https://img.shields.io/badge/readme%20style-standard-brightgreen.svg?style=flat)](https://github.com/RichardLitt/standard-readme) +[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fmistakia%2Frecord-app.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Fmistakia%2Frecord-app?ref=badge_shield) > Desktop, mobile and web app for Record. -A proof of concept distributed social & music application (library, sharing, discovery & curation) network built entirely on [IPFS](https://github.com/ipfs/js-ipfs). User data is stored via a [scuttlebot](http://scuttlebot.io/)-like immutable log via [IPFS-Log](https://github.com/orbitdb/ipfs-log) & [OrbitDB](https://github.com/orbitdb/orbit-db). +*Note: This repo is the React & React Native UI for [Record Node](https://github.com/mistakia/record-node).* + +Record is a proof of concept immutable distributed system for audio files. Built entirely on [IPFS](https://github.com/ipfs/js-ipfs), user data is stored in a [scuttlebot](http://scuttlebot.io/)-esque immutable log via [IPFS-Log](https://github.com/orbitdb/ipfs-log) & [OrbitDB](https://github.com/orbitdb/orbit-db). Bootstraping/peer discovery is done via [bitboot](https://github.com/tintfoundation/bitboot). + +At it's core, the application intends to be a media library management & playback system akin to [beets](https://github.com/beetbox/beets) with the ability to join various sources of music like [tomahawk player](https://github.com/tomahawk-player/tomahawk). By building everything on top of IPFS, it can become a connected network of libraries, opening the door to many other possibilities (i.e. soundcloud & musicbrainz), while still being entirely distributed and thus being able to function permanently. + +## Features +- Supports: mp3, mp4, m4a/aac, flac, wav, ogg, 3gpp, aiff +- Audio file tag support via [Music Metadata](https://github.com/Borewit/music-metadata) +- Audio fingerprinting via [Chromaprint](https://acoustid.org/chromaprint) +- Listening history w/ counter and timestamps +- Tagging system for organization +- Play / Shuffle search results & organizational tags +- Import files from the local file system +- Import from various web-based sources: Youtube, Soundcloud, Bandcamp, etc + - [Record Chrome extension](https://github.com/mistakia/record-chrome-extension) +- Content deduplication +- Play queue + +**Future Features** +- Metadata cleaning / import using: discogs, musicbrainz, last.fm, allmusic, beatport, streaming services, etc +- Media server (MPD, Sonos, Plex, Kodi, Emby) +- Audio and music analysis (Aubio, Essentia) +- Audio Scrobbling (last.fm, libre.fm, listenbrainz) +- Trustless timestamping / distributed copyrighting & distribution (OpenTimestamps, Nano, etc) + +[![Record v0.0.1-alpha](resources/images/screenshot.png)](https://youtu.be/1cmxiwPBv7A) -##### Record Node -This repo is a react & react native UI for [Record Node](https://github.com/mistakia/record-node). +## More Info +- Read the [wiki](https://bafybeidk4zev2jlw2jijtdyufo3itspx45k4ynq634x4rjm6ycjfdvxfrq.ipfs.infura-ipfs.io/) for a primer. +- Check out [the roadmap](https://github.com/mistakia/record-app/projects/1) to view planned features. + +
+ Installation & Usage (Development) ## Install ``` -npm install +yarn install ``` ## Usage ### Desktop (Electron) ``` -npm run start:electron +yarn start:electron ``` ### Mobile (React Native) -First, start react native packager with: +First, install packages needed by nodejs-mobile: +``` +yarn install:nodejs-mobile +``` + +Then, start react native packager with: ``` -npm run start +yarn start:rn ``` #### iOS ``` -npm run start:ios +yarn build:ios // or `yarn build:ios:dev` +yarn start:ios // or open & build with xcode `open ios/Record.xcodeproj/` ``` #### Android ``` -npm run start:android +yarn build:android +yarn start:android ``` +
## License MIT + + +[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fmistakia%2Frecord-app.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fmistakia%2Frecord-app?ref=badge_large) diff --git a/android/app/build.gradle b/android/app/build.gradle index 4515bc10..36c5f610 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -137,8 +137,10 @@ android { } dependencies { + implementation project(':nodejs-mobile-react-native') + compile project(':react-native-vector-icons') compile project(':react-native-fs') - compile project(':nodejs-mobile-react-native') + compile project(':react-native-audio-polyfill') compile fileTree(dir: "libs", include: ["*.jar"]) compile "com.android.support:appcompat-v7:23.0.1" compile "com.facebook.react:react-native:+" // From node_modules diff --git a/android/app/src/main/assets/fonts/Entypo.ttf b/android/app/src/main/assets/fonts/Entypo.ttf new file mode 100644 index 00000000..1c8f5e91 Binary files /dev/null and b/android/app/src/main/assets/fonts/Entypo.ttf differ diff --git a/android/app/src/main/assets/fonts/EvilIcons.ttf b/android/app/src/main/assets/fonts/EvilIcons.ttf new file mode 100644 index 00000000..b270f985 Binary files /dev/null and b/android/app/src/main/assets/fonts/EvilIcons.ttf differ diff --git a/android/app/src/main/assets/fonts/Feather.ttf b/android/app/src/main/assets/fonts/Feather.ttf new file mode 100755 index 00000000..244854c5 Binary files /dev/null and b/android/app/src/main/assets/fonts/Feather.ttf differ diff --git a/android/app/src/main/assets/fonts/FontAwesome.ttf b/android/app/src/main/assets/fonts/FontAwesome.ttf new file mode 100644 index 00000000..35acda2f Binary files /dev/null and b/android/app/src/main/assets/fonts/FontAwesome.ttf differ diff --git a/android/app/src/main/assets/fonts/Foundation.ttf b/android/app/src/main/assets/fonts/Foundation.ttf new file mode 100644 index 00000000..6cce217d Binary files /dev/null and b/android/app/src/main/assets/fonts/Foundation.ttf differ diff --git a/android/app/src/main/assets/fonts/Ionicons.ttf b/android/app/src/main/assets/fonts/Ionicons.ttf new file mode 100644 index 00000000..307ad889 Binary files /dev/null and b/android/app/src/main/assets/fonts/Ionicons.ttf differ diff --git a/android/app/src/main/assets/fonts/MaterialCommunityIcons.ttf b/android/app/src/main/assets/fonts/MaterialCommunityIcons.ttf new file mode 100644 index 00000000..82524a0c Binary files /dev/null and b/android/app/src/main/assets/fonts/MaterialCommunityIcons.ttf differ diff --git a/android/app/src/main/assets/fonts/MaterialIcons.ttf b/android/app/src/main/assets/fonts/MaterialIcons.ttf new file mode 100644 index 00000000..7015564a Binary files /dev/null and b/android/app/src/main/assets/fonts/MaterialIcons.ttf differ diff --git a/android/app/src/main/assets/fonts/Octicons.ttf b/android/app/src/main/assets/fonts/Octicons.ttf new file mode 100644 index 00000000..09f5a96c Binary files /dev/null and b/android/app/src/main/assets/fonts/Octicons.ttf differ diff --git a/android/app/src/main/assets/fonts/SimpleLineIcons.ttf b/android/app/src/main/assets/fonts/SimpleLineIcons.ttf new file mode 100644 index 00000000..6ecb6868 Binary files /dev/null and b/android/app/src/main/assets/fonts/SimpleLineIcons.ttf differ diff --git a/android/app/src/main/assets/fonts/Zocial.ttf b/android/app/src/main/assets/fonts/Zocial.ttf new file mode 100644 index 00000000..e4ae46c6 Binary files /dev/null and b/android/app/src/main/assets/fonts/Zocial.ttf differ diff --git a/android/app/src/main/java/com/record/MainApplication.java b/android/app/src/main/java/com/record/MainApplication.java index 1221fbc5..a3e95e3a 100644 --- a/android/app/src/main/java/com/record/MainApplication.java +++ b/android/app/src/main/java/com/record/MainApplication.java @@ -3,8 +3,10 @@ import android.app.Application; import com.facebook.react.ReactApplication; -import com.rnfs.RNFSPackage; import com.janeasystems.rn_nodejs_mobile.RNNodeJsMobilePackage; +import com.oblador.vectoricons.VectorIconsPackage; +import com.rnfs.RNFSPackage; +import io.fata.polyfill.audio.RNAudioPackage; import com.facebook.react.ReactNativeHost; import com.facebook.react.ReactPackage; import com.facebook.react.shell.MainReactPackage; @@ -25,8 +27,10 @@ public boolean getUseDeveloperSupport() { protected List getPackages() { return Arrays.asList( new MainReactPackage(), + new RNNodeJsMobilePackage(), + new VectorIconsPackage(), new RNFSPackage(), - new RNNodeJsMobilePackage() + new RNAudioPackage() ); } diff --git a/android/settings.gradle b/android/settings.gradle index 74b8f679..9a8042fa 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,7 +1,11 @@ rootProject.name = 'Record' -include ':react-native-fs' -project(':react-native-fs').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-fs/android') include ':nodejs-mobile-react-native' project(':nodejs-mobile-react-native').projectDir = new File(rootProject.projectDir, '../node_modules/nodejs-mobile-react-native/android') +include ':react-native-vector-icons' +project(':react-native-vector-icons').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-vector-icons/android') +include ':react-native-fs' +project(':react-native-fs').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-fs/android') +include ':react-native-audio-polyfill' +project(':react-native-audio-polyfill').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-audio-polyfill/android') include ':app' diff --git a/app/app-updater.js b/app/app-updater.js new file mode 100644 index 00000000..ae3e03dc --- /dev/null +++ b/app/app-updater.js @@ -0,0 +1,164 @@ +import { autoUpdater } from 'electron-updater' +import { app, BrowserWindow, shell, dialog, Notification, ipcMain } from 'electron' +import os from 'os' + +const IS_MAC = os.platform() === 'darwin' +const IS_WIN = os.platform() === 'win32' +const IS_APPIMAGE = typeof process.env.APPIMAGE !== 'undefined' + +const stopIpfs = () => new Promise((resolve, reject) => { + ipcMain.send('STOP_IPFS') + ipcMain.on('IPFS_STOPPED', resolve) +}) + +export default class AppUpdater { + constructor (log) { + this.feedback = false + autoUpdater.log = log + autoUpdater.autoDownload = false + // applicable only on Windows and Linux. + autoUpdater.autoInstallOnAppQuit = !IS_MAC + + autoUpdater.on('error', err => { + log.error(`[updater] ${err.toString()}`) + + if (!this.feedback) { + return + } + + this.feedback = false + dialog.showMessageBoxSync({ + title: 'Could not download update', + message: 'Failed to download the update. Please check your Internet connection and try again.', + type: 'error', + buttons: [ 'Close' ] + }) + }) + + autoUpdater.on('update-available', async ({ version, releaseNotes }) => { + log.info(`[updater] update to ${version} available, download will start`) + + try { + await autoUpdater.downloadUpdate() + } catch (err) { + log.error(`[updater] ${err.toString()}`) + } + + if (!this.feedback) { + return + } + + // do not toggle feedback off here so we can show a dialog once the download + // is finished. + + const opt = dialog.showMessageBoxSync({ + title: 'Update available', + message: `A new version (${version}) of Record App is available. The download will begin shortly in the background.`, + type: 'info', + buttons: [ + 'Close', + 'Read Release Notes' + ], + cancelId: 0, + defaultId: 1 + }) + + if (opt === 1) { + shell.openExternal(`https://github.com/mistakia/record-app/releases/v${version}`) + } + }) + + autoUpdater.on('update-not-available', ({ version }) => { + log.info('[updater] update not available') + + if (!this.feedback) { + return + } + + this.feedback = false + dialog.showMessageBoxSync({ + title: 'Update not available', + message: `You are on the latest version of IPFS Desktop (${version})`, + type: 'info', + buttons: [ 'Close' ] + }) + }) + + autoUpdater.on('update-downloaded', ({ version }) => { + log.info(`[updater] update to ${version} downloaded`) + + const { autoInstallOnAppQuit } = autoUpdater + const install = () => { + // Do nothing if install is handled by upstream logic + if (autoInstallOnAppQuit) return + // Else, do custom install handling + setImmediate(async () => { + // https://github.com/electron-userland/electron-builder/issues/1604#issuecomment-372091881 + if (IS_MAC) { + app.removeAllListeners('window-all-closed') + const browserWindows = BrowserWindow.getAllWindows() + browserWindows.forEach(function (browserWindow) { + browserWindow.removeAllListeners('close') + }) + + try { + await stopIpfs() + log.info('[quit-and-install] stopIpfs had finished with status') + } catch (err) { + log.error('[quit-and-install] stopIpfs had an error', err) + } + + autoUpdater.quitAndInstall(true, true) + } + }) + } + + if (!this.feedback) { + const not = new Notification({ + title: 'Update downloaded', + body: `Update for version ${version} of IPFS Desktop downloaded. Click this notification to install.` + }) + not.on('click', install) + not.show() + } + + this.feedback = false + + dialog.showMessageBoxSync({ + title: 'Update downloaded', + message: `Update for version ${version} downloaded. To install the update, please restart Record App.`, + type: 'info', + buttons: [ + autoInstallOnAppQuit ? 'Ok' : 'Restart' + ] + }) + + install() + }) + } + + setup () { + this.check() + setInterval(this.check, 360000) // every hour + } + + async check () { + if (process.env.NODE_ENV === 'development') return + + try { + await autoUpdater.checkForUpdates() + } catch (_) { + // Ignore. The errors are already handled on 'error' event. + } + } + + async manualCheck () { + if (!(IS_MAC || IS_WIN || IS_APPIMAGE)) { + shell.openExternal('https://github.com/mistakia/record-app/releases/latest') + return + } + + this.feedback = true + this.check() + } +} diff --git a/app/background.dev.js b/app/background.dev.js new file mode 100644 index 00000000..57e50a18 --- /dev/null +++ b/app/background.dev.js @@ -0,0 +1,149 @@ +require('v8-compile-cache') + +const fs = require('fs') +const path = require('path') +const jsonfile = require('jsonfile') +const RecordNode = require('record-node') +const electron = require('electron') + +const createIPFSDaemon = require('record-ipfsd') + +const { chromaprintPath, ffmpegPath } = require('./binaries') +const log = require('./logger') + +const ipc = electron.ipcRenderer +const { app, dialog } = electron.remote + +const getIpfsBinPath = () => require('go-ipfs') + .path() + .replace('app.asar', 'app.asar.unpacked') + +const isDev = process.env.NODE_ENV === 'development' +log.info(`process id: ${process.pid}, isDev: ${isDev}`) + +let record +let ipfsd + +const main = async () => { + const recorddir = path.resolve(isDev ? app.getPath('temp') : app.getPath('appData'), './record') + if (!fs.existsSync(recorddir)) { fs.mkdirSync(recorddir) } + + const infoPath = path.resolve(recorddir, 'info.json') + const info = (fs.existsSync(infoPath) && jsonfile.readFileSync(infoPath)) || {} + const orbitAddress = info.address || 'record' + const id = info.id + log.info(`ID: ${id}`) + log.info(`Orbit Address: ${orbitAddress}`) + let opts = { + directory: recorddir, + store: { + replicationConcurrency: 240 + }, + logger: { + info: log.info.bind(log), + error: log.error.bind(log) + }, + address: orbitAddress, + api: true, + chromaprintPath, + ffmpegPath + } + + if (isDev) { + opts.api = { port: 3001 } + opts.bitboot = { enabled: false } + } + + if (id) { + opts.id = id + } + + if (app.isPackaged) { + opts.youtubedlPath = path.join(path.dirname(app.getAppPath()), 'app.asar.unpacked/node_modules/youtube-dl/bin/youtube-dl') + } + + record = new RecordNode(opts) + record.on('id', (data) => { + jsonfile.writeFileSync(infoPath, { + id: data.id, + address: data.orbitdb.address + }, { spaces: 2 }) + }) + + record.on('ready', async (data) => { + try { + log.info(JSON.stringify(data, null, 2)) + ipc.send('ready', data) + record.on('redux', data => ipc.send('redux', data)) + + jsonfile.writeFileSync(infoPath, { + id: data.id, + address: data.orbitdb.address + }, { spaces: 2 }) + + setTimeout(() => record.logs.connect(), 5000) + } catch (error) { + log.error(error) + } + }) + + try { + ipfsd = await createIPFSDaemon({ + repo: path.resolve(recorddir, 'ipfs'), + log: log.info.bind(log), + ipfsBin: getIpfsBinPath() + }) + await record.init(ipfsd) + } catch (error) { + log.error(error) + + await dialog.showMessageBox({ + type: 'error', + message: 'Startup error, please restart.', + detail: error.toString() + }) + + if ( + process.env.NODE_ENV === 'production' && + process.env.DEBUG_PROD !== 'true' + ) { + ipc.send('error') + } + } +} + +try { + main() +} catch (error) { + log.error(error) +} + +const handler = async () => { + log.info(`Online: ${navigator.onLine}`) + + if (ipfsd && navigator.onLine) { + // await ipfsd.stop() + // await ipfsd.start() + } +} + +ipc.on('STOP_IPFS', async () => { + await ipfsd.stop() + ipc.send('IPFS_STOPPED') +}) +window.addEventListener('online', handler) +window.addEventListener('offline', handler) +window.onbeforeunload = async (e) => { + if (ipfsd) { + await ipfsd.stop() + log.info('ipfs shutdown successfully') + } + + if (record) { + await record.stop() + log.info('record shutdown successfully') + } + window.onbeforeunload = null + app.exit() + e.returnValue = false +} diff --git a/app/background.html b/app/background.html new file mode 100644 index 00000000..8a9ef822 --- /dev/null +++ b/app/background.html @@ -0,0 +1,29 @@ + + + + + Record + + + + + diff --git a/app/background.prod.js.LICENSE.txt b/app/background.prod.js.LICENSE.txt new file mode 100644 index 00000000..425d3f09 --- /dev/null +++ b/app/background.prod.js.LICENSE.txt @@ -0,0 +1,352 @@ +/*! + * prr + * (c) 2013 Rod Vagg + * https://github.com/rvagg/prr + * License: MIT + */ + +/*! + * @description Recursive object extending + * @author Viacheslav Lotsmanov + * @license MIT + * + * The MIT License (MIT) + * + * Copyright (c) 2013-2018 Viacheslav Lotsmanov + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +/*! + * accepts + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * basic-auth + * Copyright(c) 2013 TJ Holowaychuk + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2015-2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * body-parser + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2014-2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * body-parser + * Copyright(c) 2014-2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * bytes + * Copyright(c) 2012-2014 TJ Holowaychuk + * Copyright(c) 2015 Jed Watson + * MIT Licensed + */ + +/*! + * content-disposition + * Copyright(c) 2014-2017 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * content-type + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * cookie + * Copyright(c) 2012-2014 Roman Shtylman + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * depd + * Copyright(c) 2014 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * depd + * Copyright(c) 2014-2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * depd + * Copyright(c) 2014-2017 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * depd + * Copyright(c) 2014-2018 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * depd + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * destroy + * Copyright(c) 2014 Jonathan Ong + * MIT Licensed + */ + +/*! + * ee-first + * Copyright(c) 2014 Jonathan Ong + * MIT Licensed + */ + +/*! + * encodeurl + * Copyright(c) 2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * escape-html + * Copyright(c) 2012-2013 TJ Holowaychuk + * Copyright(c) 2015 Andreas Lubbe + * Copyright(c) 2015 Tiancheng "Timothy" Gu + * MIT Licensed + */ + +/*! + * etag + * Copyright(c) 2014-2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * express + * Copyright(c) 2009-2013 TJ Holowaychuk + * Copyright(c) 2013 Roman Shtylman + * Copyright(c) 2014-2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * express + * Copyright(c) 2009-2013 TJ Holowaychuk + * Copyright(c) 2014-2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * finalhandler + * Copyright(c) 2014-2017 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * forwarded + * Copyright(c) 2014-2017 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * fresh + * Copyright(c) 2012 TJ Holowaychuk + * Copyright(c) 2016-2017 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * http-errors + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * media-typer + * Copyright(c) 2014 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * media-typer + * Copyright(c) 2014-2017 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * merge-descriptors + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * methods + * Copyright(c) 2013-2014 TJ Holowaychuk + * Copyright(c) 2015-2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * mime-db + * Copyright(c) 2014 Jonathan Ong + * MIT Licensed + */ + +/*! + * mime-types + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * morgan + * Copyright(c) 2010 Sencha Inc. + * Copyright(c) 2011 TJ Holowaychuk + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2014-2017 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * negotiator + * Copyright(c) 2012 Federico Romero + * Copyright(c) 2012-2014 Isaac Z. Schlueter + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * on-finished + * Copyright(c) 2013 Jonathan Ong + * Copyright(c) 2014 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * on-headers + * Copyright(c) 2014 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * parseurl + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2014-2017 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * proxy-addr + * Copyright(c) 2014-2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * range-parser + * Copyright(c) 2012-2014 TJ Holowaychuk + * Copyright(c) 2015-2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * raw-body + * Copyright(c) 2013-2014 Jonathan Ong + * Copyright(c) 2014-2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * send + * Copyright(c) 2012 TJ Holowaychuk + * Copyright(c) 2014-2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * serve-static + * Copyright(c) 2010 Sencha Inc. + * Copyright(c) 2011 TJ Holowaychuk + * Copyright(c) 2014-2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * statuses + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * toidentifier + * Copyright(c) 2016 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * type-is + * Copyright(c) 2014 Jonathan Ong + * Copyright(c) 2014-2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * unpipe + * Copyright(c) 2015 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! + * vary + * Copyright(c) 2014-2017 Douglas Christopher Wilson + * MIT Licensed + */ + +/*! ieee754. BSD-3-Clause License. Feross Aboukhadijeh */ + +/*! safe-buffer. MIT License. Feross Aboukhadijeh */ + +/*!*/ + +/** + * [js-sha3]{@link https://github.com/emn178/js-sha3} + * + * @version 0.8.0 + * @author Chen, Yi-Cyuan [emn178@gmail.com] + * @copyright Chen, Yi-Cyuan 2015-2018 + * @license MIT + */ + +//! stable.js 0.1.8, https://github.com/Two-Screen/stable + +//! © 2018 Angry Bytes and contributors. MIT licensed. diff --git a/app/binaries.js b/app/binaries.js new file mode 100644 index 00000000..bd789298 --- /dev/null +++ b/app/binaries.js @@ -0,0 +1,35 @@ +'use strict' + +const path = require('path') +const { remote } = require('electron') +const { platform } = require('os') + +const IS_PROD = process.env.NODE_ENV === 'production' +const root = process.cwd() +const { isPackaged, getAppPath } = remote.app + +const getPlatform = () => { + switch (platform()) { + case 'aix': + case 'freebsd': + case 'linux': + case 'openbsd': + case 'android': + return 'linux' + case 'darwin': + case 'sunos': + return 'mac' + case 'win32': + return 'win' + } +} + +const binariesPath = + IS_PROD && isPackaged + ? path.join(path.dirname(getAppPath()), '..', './Resources', './bin') + : path.join(root, './resources', getPlatform(), './bin') + +module.exports = { + chromaprintPath: path.resolve(path.join(binariesPath, './fpcalc')), + ffmpegPath: path.resolve(path.join(binariesPath, './ffmpeg')) +} diff --git a/app/core/about/actions.js b/app/core/about/actions.js new file mode 100644 index 00000000..0d4b71ad --- /dev/null +++ b/app/core/about/actions.js @@ -0,0 +1,43 @@ +export const aboutActions = { + SET_ABOUT: 'SET_ABOUT', + + POST_ABOUT_FAILED: 'POST_ABOUT_FAILED', + POST_ABOUT_FULFILLED: 'POST_ABOUT_FULFILLED', + POST_ABOUT_PENDING: 'POST_ABOUT_PENDING', + + postAboutFailed: (address, error) => ({ + type: aboutActions.POST_ABOUT_FAILED, + payload: { + address, + error + } + }), + + postAboutFulfilled: (address, data) => ({ + type: aboutActions.POST_ABOUT_FULFILLED, + payload: { + address, + data + } + }), + + postAboutPending: address => ({ + type: aboutActions.POST_ABOUT_PENDING, + payload: { + address + } + }), + + setAbout: (data) => ({ + type: aboutActions.SET_ABOUT, + payload: { + data + } + }) +} + +export const aboutPostActions = { + failed: aboutActions.postAboutFailed, + fulfilled: aboutActions.postAboutFulfilled, + pending: aboutActions.postAboutPending +} diff --git a/app/core/about/index.js b/app/core/about/index.js new file mode 100644 index 00000000..e2c3308d --- /dev/null +++ b/app/core/about/index.js @@ -0,0 +1,4 @@ +export { aboutActions, aboutPostActions } from './actions' +export { aboutSagas } from './sagas' +export { getAboutIsUpdating } from './selectors' +export { aboutReducer } from './reducer' diff --git a/app/core/about/reducer.js b/app/core/about/reducer.js new file mode 100644 index 00000000..03de75fa --- /dev/null +++ b/app/core/about/reducer.js @@ -0,0 +1,20 @@ +import { Record } from 'immutable' +import { aboutActions } from './actions' + +const initialState = new Record({ + isUpdating: false +}) + +export function aboutReducer (state = initialState(), { payload, type }) { + switch (type) { + case aboutActions.POST_ABOUT_PENDING: + return state.set('isUpdating', true) + + case aboutActions.POST_ABOUT_FAILED: + case aboutActions.POST_ABOUT_FULFILLED: + return state.set('isUpdating', false) + + default: + return state + } +} diff --git a/app/core/about/sagas.js b/app/core/about/sagas.js new file mode 100644 index 00000000..61a9159a --- /dev/null +++ b/app/core/about/sagas.js @@ -0,0 +1,38 @@ +import { call, fork, takeLatest, select, put } from 'redux-saga/effects' + +import { postAbout } from '@core/api' +import { aboutActions } from './actions' +import { getApp, goBack } from '@core/app' +import { notificationActions } from '@core/notifications' + +export function * setAbout ({ payload }) { + const app = yield select(getApp) + const { data } = payload + yield call(postAbout, { address: app.address, data }) +} + +export function * setAboutFailed () { + yield put(notificationActions.show({ + text: 'Profile update failed', + dismiss: 2000, + severity: 'error' + })) +} + +export function * watchSetAbout () { + yield takeLatest(aboutActions.SET_ABOUT, setAbout) +} + +export function * watchSetAboutFulfilled () { + yield takeLatest(aboutActions.POST_ABOUT_FULFILLED, goBack) +} + +export function * watchSetAboutFailed () { + yield takeLatest(aboutActions.POST_ABOUT_FAILED, setAboutFailed) +} + +export const aboutSagas = [ + fork(watchSetAbout), + fork(watchSetAboutFulfilled), + fork(watchSetAboutFailed) +] diff --git a/app/core/about/selectors.js b/app/core/about/selectors.js new file mode 100644 index 00000000..b7d18109 --- /dev/null +++ b/app/core/about/selectors.js @@ -0,0 +1,3 @@ +export function getAboutIsUpdating (state) { + return state.get('about').isUpdating +} diff --git a/app/core/api/index.js b/app/core/api/index.js new file mode 100644 index 00000000..47bb998c --- /dev/null +++ b/app/core/api/index.js @@ -0,0 +1,26 @@ +export { + postListen, + fetchListens, + fetchLogs, + fetchInfo, + fetchPeers, + fetchPrivateKey, + fetchLog, + fetchAllLogs, + fetchTags, + postTag, + deleteTag, + deleteLogLink, + fetchShuffleTracks, + fetchPlayerTracks, + fetchTracks, + postLogLink, + deleteLog, + postAbout, + postTrack, + postImporter, + deleteTrack, + postIdentity, + requestConnectLog, + requestDisconnectLog +} from './sagas' diff --git a/app/core/api/sagas.js b/app/core/api/sagas.js new file mode 100644 index 00000000..9a2ed4f6 --- /dev/null +++ b/app/core/api/sagas.js @@ -0,0 +1,106 @@ +import { race, call, put, take, cancelled } from 'redux-saga/effects' +import { api, apiRequest } from '@core/api/service' +import { LOCATION_CHANGE } from 'react-router-redux' + +import { + setIdentityActions, + getPrivateKeyActions +} from '@core/app' + +import { + loglistRequestActions, + loglistPostActions, + loglistDeleteActions, + allLoglistRequestActions, + peerLoglistRequestActions +} from '@core/loglists' +import { infoRequestActions } from '@core/info' +import { + taglistRequestActions, + taglistPostActions, + taglistDeleteActions +} from '@core/taglists' +import { importerPostActions } from '@core/importer' +import { + tracklistRequestActions, + tracklistPostActions, + tracklistDeleteActions +} from '@core/tracklists' +import { + logRequestActions, + logDeleteActions, + logConnectActions, + logDisconnectActions +} from '@core/logs' +import { + aboutPostActions +} from '@core/about' +import { + playerShuffleRequestActions, + playerTracksRequestActions +} from '@core/player' +import { + listensRequestActions, + listenPostActions +} from '@core/listens' + +function * fetchAPI (apiFunction, actions, opts = {}) { + const { abort, request } = apiRequest(apiFunction, opts) + try { + yield put(actions.pending(opts.address)) + const data = yield call(request) + yield put(actions.fulfilled(opts.address, data)) + } catch (err) { + console.log(err) + yield put(actions.failed(opts.address, err.toString())) + } finally { + if (yield cancelled()) { + abort() + } + } +} + +function * fetch (...args) { + const fn = args[0] + if (fn === api.postLogLink) { + return yield call(fetchAPI.bind(null, ...args)) + } + + yield race([ + call(fetchAPI.bind(null, ...args)), + take(LOCATION_CHANGE) + ]) +} + +export const fetchLogs = fetch.bind(null, api.fetchLogs, loglistRequestActions) +export const postLogLink = fetch.bind(null, api.postLogLink, loglistPostActions) +export const deleteLogLink = fetch.bind(null, api.deleteLogLink, loglistDeleteActions) +export const requestConnectLog = fetch.bind(null, api.connectLog, logConnectActions) +export const requestDisconnectLog = fetch.bind(null, api.disconnectLog, logDisconnectActions) + +export const fetchAllLogs = fetch.bind(null, api.fetchAllLogs, allLoglistRequestActions) +export const fetchPeers = fetch.bind(null, api.fetchPeers, peerLoglistRequestActions) + +export const fetchInfo = fetch.bind(null, api.fetchInfo, infoRequestActions) + +export const fetchTags = fetch.bind(null, api.fetchTags, taglistRequestActions) +export const postTag = fetch.bind(null, api.postTag, taglistPostActions) +export const deleteTag = fetch.bind(null, api.deleteTag, taglistDeleteActions) + +export const fetchShuffleTracks = fetch.bind(null, api.fetchTracks, playerShuffleRequestActions) +export const fetchPlayerTracks = fetch.bind(null, api.fetchTracks, playerTracksRequestActions) +export const fetchTracks = fetch.bind(null, api.fetchTracks, tracklistRequestActions) +export const postTrack = fetch.bind(null, api.postTrack, tracklistPostActions) +export const postImporter = fetch.bind(null, api.postImporter, importerPostActions) +export const deleteTrack = fetch.bind(null, api.deleteTrack, tracklistDeleteActions) + +export const fetchLog = fetch.bind(null, api.fetchLog, logRequestActions) +export const postAbout = fetch.bind(null, api.postAbout, aboutPostActions) + +export const postIdentity = fetch.bind(null, api.postIdentity, setIdentityActions) +export const fetchPrivateKey = fetch.bind(null, api.fetchPrivateKey, getPrivateKeyActions) + +export const deleteLog = fetch.bind(null, api.deleteLog, logDeleteActions) + +export const fetchListens = fetch.bind(null, api.fetchListens, listensRequestActions) +export const postListen = fetch.bind(null, api.postListen, listenPostActions) diff --git a/app/core/api/service.js b/app/core/api/service.js new file mode 100644 index 00000000..7d755721 --- /dev/null +++ b/app/core/api/service.js @@ -0,0 +1,136 @@ +/* global fetch, AbortController */ + +import queryString from 'query-string' + +import { BASE_URL } from '@core/constants' + +const POST = (data) => ({ + method: 'POST', + body: JSON.stringify(data), + headers: { + 'Content-Type': 'application/json' + } +}) + +const DELETE = { + method: 'DELETE' +} + +export const api = { + fetchListens ({ params }) { + const url = `${BASE_URL}/listens?${queryString.stringify(params)}` + return { url } + }, + postListen ({ address, data }) { + const url = `${BASE_URL}/listens` + const post = POST(data) + return { url, ...post } + }, + fetchLogs ({ address }) { + const url = `${BASE_URL}/logs${address}` + return { url } + }, + fetchInfo () { + const url = `${BASE_URL}/settings` + return { url } + }, + fetchPeers () { + const url = `${BASE_URL}/peers` + return { url } + }, + fetchPrivateKey () { + const url = `${BASE_URL}/export` + return { url } + }, + fetchLog ({ address }) { + const url = `${BASE_URL}/log${address}` + return { url } + }, + fetchAllLogs () { + const url = `${BASE_URL}/logs/all` + return { url } + }, + fetchTags ({ params }) { + const url = `${BASE_URL}/tags?${queryString.stringify(params)}` + return { url } + }, + fetchTracks ({ params }) { + const url = `${BASE_URL}/tracks?${queryString.stringify(params)}` + return { url } + }, + postTag ({ address, data }) { + const url = `${BASE_URL}/tags` + const post = POST(data) + return { url, ...post } + }, + deleteTag ({ address, data }) { + const url = `${BASE_URL}/tags?${queryString.stringify(data)}` + return { url, ...DELETE } + }, + postLogLink ({ address, data }) { + const url = `${BASE_URL}/logs` + const post = POST(data) + return { url, ...post } + }, + deleteLogLink ({ address, data }) { + const url = `${BASE_URL}/logs?${queryString.stringify(data)}` + return { url, ...DELETE } + }, + postAbout ({ data }) { + const url = `${BASE_URL}/about` + const post = POST(data) + return { url, ...post } + }, + postTrack ({ data }) { + const url = `${BASE_URL}/tracks` + const post = POST(data) + return { url, ...post } + }, + postImporter ({ data }) { + const url = `${BASE_URL}/importer` + const post = POST(data) + return { url, ...post } + }, + deleteTrack ({ data }) { + const url = `${BASE_URL}/tracks?${queryString.stringify(data)}` + return { url, ...DELETE } + }, + postIdentity ({ privateKey }) { + const url = `${BASE_URL}/import` + const post = POST({ privateKey }) + return { url, ...post } + }, + connectLog ({ address }) { + const url = `${BASE_URL}/connect${address}` + return { url } + }, + disconnectLog ({ address }) { + const url = `${BASE_URL}/disconnect${address}` + return { url } + }, + deleteLog ({ address }) { + const url = `${BASE_URL}/log${address}` + return { url, ...DELETE } + } +} + +export const apiRequest = (apiFunction, opts) => { + const controller = new AbortController() + const abort = controller.abort.bind(controller) + const options = Object.assign(apiFunction(opts), controller.signal) + const request = dispatchFetch.bind(null, options) + return { abort, request } +} + +export const dispatchFetch = (options) => { + return fetch(options.url, options).then(response => { + const res = response.json() + if (response.status >= 200 && response.status < 300) { + return res + } else { + const error = new Error(res.error || response.statusText) + error.response = response + throw error + } + }) +} diff --git a/app/core/app/actions.js b/app/core/app/actions.js new file mode 100644 index 00000000..506ad241 --- /dev/null +++ b/app/core/app/actions.js @@ -0,0 +1,72 @@ +export const appActions = { + INIT_APP: 'INIT_APP', + + LOGS_CONNECTED: 'LOGS_CONNECTED', + LOGS_DISCONNECTED: 'LOGS_DISCONNECTED', + + SET_IDENTITY: 'SET_IDENTITY', + + SET_IDENTITY_FAILED: 'SET_IDENTITY_FAILED', + SET_IDENTITY_FULFILLED: 'SET_IDENTITY_FULFILLED', + SET_IDENTITY_PENDING: 'SET_IDENTITY_PENDING', + + GET_PRIVATE_KEY: 'GET_PRIVATE_KEY', + + GET_PRIVATE_KEY_FAILED: 'GET_PRIVATE_KEY_FAILED', + GET_PRIVATE_KEY_PENDING: 'GET_PRIVATE_KEY_PENDING', + GET_PRIVATE_KEY_FULFILLED: 'GET_PRIVATE_KEY_FULFILLED', + + initApp: (data) => ({ + type: appActions.INIT_APP, + payload: data + }), + + setIdentity: (pk) => ({ + type: appActions.SET_IDENTITY, + payload: pk + }), + + setIdentityFailed: error => ({ + type: appActions.SET_IDENTITY_FAILED, + payload: error + }), + + setIdentityPending: () => ({ + type: appActions.SET_IDENTITY_PENDING + }), + + setIdentityFulfilled: (address, data) => ({ + type: appActions.SET_IDENTITY_FULFILLED, + payload: data + }), + + getPrivateKey: () => ({ + type: appActions.GET_PRIVATE_KEY + }), + + getPrivateKeyFailed: error => ({ + type: appActions.GET_PRIVATE_KEY_FAILED, + payload: error + }), + + getPrivateKeyFulfilled: (address, data) => ({ + type: appActions.GET_PRIVATE_KEY_FULFILLED, + payload: data + }), + + getPrivateKeyPending: () => ({ + type: appActions.GET_PRIVATE_KEY_PENDING + }) +} + +export const setIdentityActions = { + failed: appActions.setIdentityFailed, + fulfilled: appActions.setIdentityFulfilled, + pending: appActions.setIdentityPending +} + +export const getPrivateKeyActions = { + failed: appActions.getPrivateKeyFailed, + fulfilled: appActions.getPrivateKeyFulfilled, + pending: appActions.getPrivateKeyPending +} diff --git a/app/core/app/index.js b/app/core/app/index.js new file mode 100644 index 00000000..f745eff9 --- /dev/null +++ b/app/core/app/index.js @@ -0,0 +1,4 @@ +export { appActions, setIdentityActions, getPrivateKeyActions } from './actions' +export { appReducer } from './reducer' +export { appSagas, goBack } from './sagas' +export { getApp } from './selectors' diff --git a/app/core/app/reducer.js b/app/core/app/reducer.js new file mode 100644 index 00000000..7d7980b8 --- /dev/null +++ b/app/core/app/reducer.js @@ -0,0 +1,48 @@ +import { Record } from 'immutable' + +import { appActions } from './actions' + +const initialState = new Record({ + id: null, + address: null, + orbitdb: new Map(), + ipfs: new Map(), + isReplicating: false, + isPending: true, + privateKey: null +}) + +export function appReducer (state = initialState(), { payload, type }) { + switch (type) { + case appActions.SET_IDENTITY_FULFILLED: + case appActions.INIT_APP: + return state.merge({ + address: payload.orbitdb.address, + privateKey: null, + isPending: false, + ...payload + }) + + case appActions.SET_IDENTITY_PENDING: + return state.merge({ + isPending: true + }) + + case appActions.SET_IDENTITY_FAILED: + return state.merge({ + isPending: false + }) + + case appActions.LOGS_CONNECTED: + return state.set('isReplicating', true) + + case appActions.LOGS_DISCONNECTED: + return state.set('isReplicating', false) + + case appActions.GET_PRIVATE_KEY_FULFILLED: + return state.set('privateKey', payload.privateKeyBytes) + + default: + return state + } +} diff --git a/app/core/app/sagas.js b/app/core/app/sagas.js new file mode 100644 index 00000000..7dbe78d4 --- /dev/null +++ b/app/core/app/sagas.js @@ -0,0 +1,68 @@ +import { call, take, fork, put, takeLatest } from 'redux-saga/effects' +import { push } from 'react-router-redux' + +import { appActions } from './actions' +import { logActions } from '@core/logs' +import { loglistActions } from '@core/loglists' +import { notificationActions } from '@core/notifications' +import { postIdentity, fetchPrivateKey } from '@core/api' + +import history from '@core/history' + +export function * watchInitApp () { + while (true) { + const { payload } = yield take(appActions.INIT_APP) + // TODO: handle error on ipfs initialization + if (payload.orbitdb.address) yield put(logActions.loadLog(payload.orbitdb.address)) + yield put(loglistActions.loadAllLogs()) + } +} + +export function * goToInfo () { + yield put(push('/info')) +} + +export function * getPrivateKey () { + yield call(fetchPrivateKey) +} + +export function * setIdentity ({ payload }) { + yield call(postIdentity, { privateKey: payload }) +} + +export function * goBack () { + yield call(history.back) +} + +export function * setIdentityFailed () { + yield put(notificationActions.show({ + text: 'Could not load account', + severity: 'error', + dismiss: 2000 + })) +} + +export function * watchGetPrivateKey () { + yield takeLatest(appActions.GET_PRIVATE_KEY, getPrivateKey) +} + +export function * watchSetIdentity () { + yield takeLatest(appActions.SET_IDENTITY, setIdentity) +} + +export function * watchSetIdentityFulfilled () { + yield takeLatest(appActions.SET_IDENTITY_FULFILLED, goToInfo) +} + +export function * watchSetIdentityFailed () { + yield takeLatest(appActions.SET_IDENTITY_FAILED, setIdentityFailed) +} + +export const appSagas = [ + fork(watchInitApp), + fork(watchGetPrivateKey), + fork(watchSetIdentity), + fork(watchSetIdentityFulfilled), + + fork(watchSetIdentityFailed) +] diff --git a/app/core/app/selectors.js b/app/core/app/selectors.js new file mode 100644 index 00000000..326741a5 --- /dev/null +++ b/app/core/app/selectors.js @@ -0,0 +1,3 @@ +export function getApp (state) { + return state.get('app').toJS() +} diff --git a/app/core/audio/audio.android.js b/app/core/audio/audio.android.js new file mode 100644 index 00000000..60747450 --- /dev/null +++ b/app/core/audio/audio.android.js @@ -0,0 +1 @@ +export { default } from './audio.native.js' diff --git a/app/core/audio/audio.ios.js b/app/core/audio/audio.ios.js new file mode 100644 index 00000000..60747450 --- /dev/null +++ b/app/core/audio/audio.ios.js @@ -0,0 +1 @@ +export { default } from './audio.native.js' diff --git a/app/core/audio/audio.js b/app/core/audio/audio.js new file mode 100644 index 00000000..354ab519 --- /dev/null +++ b/app/core/audio/audio.js @@ -0,0 +1,3 @@ +/* global Audio */ + +export default Audio diff --git a/app/core/audio/audio.native.js b/app/core/audio/audio.native.js new file mode 100644 index 00000000..9e61226a --- /dev/null +++ b/app/core/audio/audio.native.js @@ -0,0 +1,3 @@ +import Audio from 'react-native-audio-polyfill' + +export default Audio diff --git a/app/core/audio/index.js b/app/core/audio/index.js new file mode 100644 index 00000000..9222b330 --- /dev/null +++ b/app/core/audio/index.js @@ -0,0 +1 @@ +export { audio, initAudio, setVolume } from './service' diff --git a/app/core/audio/service.js b/app/core/audio/service.js new file mode 100644 index 00000000..72f9e988 --- /dev/null +++ b/app/core/audio/service.js @@ -0,0 +1,71 @@ +import { PLAYER_MAX_VOLUME, PLAYER_VOLUME_INCREMENT } from '@core/constants' +import { playerActions } from '@core/player' + +import Audio from './audio' + +let _audio + +export function initAudio (emit, audio = new Audio()) { + audio.addEventListener('ended', () => emit(playerActions.audioEnded())) + audio.addEventListener('pause', () => emit(playerActions.audioPaused())) + audio.addEventListener('playing', () => emit(playerActions.audioPlaying())) + audio.addEventListener('timeupdate', event => emit(playerActions.audioTimeUpdated(getTimes(event)))) + audio.addEventListener('volumechange', () => emit(playerActions.audioVolumeChanged(getVolume()))) + + _audio = audio + return () => {} +} + +export function getTimes (event) { + const { buffered, currentTime, duration } = event.target ? event.target : event + const bufferedTime = buffered.length ? buffered.end(0) : 0 + + return { + bufferedTime, + currentTime, + duration, + percentBuffered: `${(bufferedTime / duration * 100) || 0}%`, + percentCompleted: `${(currentTime / duration * 100) || 0}%` + } +} + +export function getVolume () { + return Math.floor(_audio.volume * 100) +} + +export function setVolume (volume) { + _audio.volume = volume / 100 +} + +export const audio = { + decreaseVolume () { + let volume = getVolume() - PLAYER_VOLUME_INCREMENT + if (volume >= 0) setVolume(volume) + }, + + increaseVolume () { + let volume = getVolume() + PLAYER_VOLUME_INCREMENT + if (volume <= PLAYER_MAX_VOLUME) setVolume(volume) + }, + + load (url) { + if (url) _audio.src = url + }, + + unload () { + _audio.src = '' + }, + + pause () { + _audio.pause() + }, + + play () { + let promise = _audio.play() + if (promise && promise.catch) promise.catch(() => {}) + }, + + seek (time) { + _audio.currentTime = time + } +} diff --git a/app/core/constants.js b/app/core/constants.js new file mode 100644 index 00000000..33d6fb65 --- /dev/null +++ b/app/core/constants.js @@ -0,0 +1,31 @@ +//= ==================================== +// GENERAL +// ------------------------------------- +export const WIKI_URL = 'http://docs.record.tint.space' +export const ITEMS_PER_LOAD = 200 +export const PEER_LOGLIST_ADDRESS = '/PEER_LOGLIST_ADDRESS' +export const ALL_LOGLIST_ADDRESS = '/ALL_LOGLIST_ADDRESS' +export const CURRENT_TRACKLIST_ADDRESS = '/CURRENT_TRACKLIST_ADDRESS' +export const CURRENT_TAGLIST_ADDRESS = '/CURRENT_TAGLIST_ADDRESS' + +const PORT = process.env.NODE_ENV === 'development' ? 3001 : 3000 +export const BASE_URL = `http://localhost:${PORT}` + +//= ==================================== +// HELP +// ------------------------------------- +export const HELP_STORAGE_KEY = 'HELP_STOAGE_KEY' +export const DEFAULT_HELP_SETTINGS = { + isTracksHelpVisible: true, + isMyTracksHelpVisible: true, + isMyLogsHelpVisible: true +} + +//= ==================================== +// PLAYER +// ------------------------------------- +export const PLAYER_INITIAL_VOLUME = 90 +export const PLAYER_MAX_VOLUME = 100 +export const PLAYER_VOLUME_INCREMENT = 5 + +export const PLAYER_STORAGE_KEY = 'PLAYER_STORAGE_KEY' diff --git a/app/core/context-menu/actions.js b/app/core/context-menu/actions.js new file mode 100644 index 00000000..02ba3b45 --- /dev/null +++ b/app/core/context-menu/actions.js @@ -0,0 +1,18 @@ +export const contextMenuActions = { + SHOW_CONTEXT_MENU: 'SHOW_CONTEXT_MENU', + HIDE_CONTEXT_MENU: 'HIDE_CONTEXT_MENU', + + hide: () => ({ + type: contextMenuActions.HIDE_CONTEXT_MENU + }), + + show: ({ id, data, clickX, clickY }) => ({ + type: contextMenuActions.SHOW_CONTEXT_MENU, + payload: { + id, + data, + clickX, + clickY + } + }) +} diff --git a/app/core/context-menu/index.js b/app/core/context-menu/index.js new file mode 100644 index 00000000..4e57f2b4 --- /dev/null +++ b/app/core/context-menu/index.js @@ -0,0 +1,3 @@ +export { contextMenuActions } from './actions' +export { contextMenuReducer } from './reducer' +export { getContextMenuInfo, getContextMenuTrack, getContextMenuLog } from './selectors' diff --git a/app/core/context-menu/reducer.js b/app/core/context-menu/reducer.js new file mode 100644 index 00000000..0e25fd41 --- /dev/null +++ b/app/core/context-menu/reducer.js @@ -0,0 +1,30 @@ +import { Record } from 'immutable' +import { contextMenuActions } from './actions' + +const ContextMenuState = new Record({ + id: null, + visible: false, + clickX: null, + clickY: null, + data: null +}) + +export function contextMenuReducer (state = new ContextMenuState(), { payload, type }) { + switch (type) { + case contextMenuActions.SHOW_CONTEXT_MENU: + const { id, data, clickX, clickY } = payload + return state.merge({ + id, + data, + clickX, + clickY, + visible: true + }) + + case contextMenuActions.HIDE_CONTEXT_MENU: + return new ContextMenuState() + + default: + return state + } +} diff --git a/app/core/context-menu/selectors.js b/app/core/context-menu/selectors.js new file mode 100644 index 00000000..aa157a41 --- /dev/null +++ b/app/core/context-menu/selectors.js @@ -0,0 +1,16 @@ +import { getTrackById } from '@core/tracks' +import { getLogByAddress } from '@core/logs' + +export function getContextMenuInfo (state) { + return state.get('contextMenu').toJS() +} + +export function getContextMenuTrack (state) { + const trackId = state.get('contextMenu').get('data').get('trackId') + return getTrackById(state, trackId) +} + +export function getContextMenuLog (state) { + const address = state.get('contextMenu').get('data').get('address') + return getLogByAddress(state, address) +} diff --git a/app/core/dialogs/actions.js b/app/core/dialogs/actions.js new file mode 100644 index 00000000..982076a2 --- /dev/null +++ b/app/core/dialogs/actions.js @@ -0,0 +1,18 @@ +export const dialogActions = { + SHOW_DIALOG: 'SHOW_DIALOG', + CLOSE_DIALOG: 'CLOSE_DIALOG', + + show: ({ type, message, detail, onConfirm }) => ({ + type: dialogActions.SHOW_DIALOG, + payload: { + type, + message, + detail, + onConfirm + } + }), + + close: () => ({ + type: dialogActions.CLOSE_DIALOG + }) +} diff --git a/app/core/dialogs/index.js b/app/core/dialogs/index.js new file mode 100644 index 00000000..90402de2 --- /dev/null +++ b/app/core/dialogs/index.js @@ -0,0 +1,3 @@ +export { dialogReducer } from './reducer' +export { dialogActions } from './actions' +export { getDialog } from './selectors' diff --git a/app/core/dialogs/reducer.js b/app/core/dialogs/reducer.js new file mode 100644 index 00000000..fa5f990c --- /dev/null +++ b/app/core/dialogs/reducer.js @@ -0,0 +1,22 @@ +import { Record } from 'immutable' + +import { dialogActions } from './actions' + +const DialogState = new Record({ + message: null, + detail: null, + onConfirm: null +}) + +export function dialogReducer (state = new DialogState(), { payload, type }) { + switch (type) { + case dialogActions.SHOW_DIALOG: + return new DialogState(payload) + + case dialogActions.CLOSE_DIALOG: + return DialogState() + + default: + return state + } +} diff --git a/app/core/dialogs/selectors.js b/app/core/dialogs/selectors.js new file mode 100644 index 00000000..e9dccae9 --- /dev/null +++ b/app/core/dialogs/selectors.js @@ -0,0 +1,3 @@ +export function getDialog (state) { + return state.get('dialog') +} diff --git a/app/core/help/actions.js b/app/core/help/actions.js new file mode 100644 index 00000000..d4493cdb --- /dev/null +++ b/app/core/help/actions.js @@ -0,0 +1,25 @@ +export const helpActions = { + TOGGLE_TRACKS_HELP: 'TOGGLE_TRACKS_HELP', + TOGGLE_MY_LOGS_HELP: 'TOGGLE_MY_LOGS_HELP', + TOGGLE_MY_TRACKS_HELP: 'TOGGLE_MY_TRACKS_HELP', + SET_HELP: 'SET_HELP', + + toggleTracksHelp: () => ({ + type: helpActions.TOGGLE_TRACKS_HELP + }), + + toggleMyLogsHelp: () => ({ + type: helpActions.TOGGLE_MY_LOGS_HELP + }), + + toggleMyTracksHelp: () => ({ + type: helpActions.TOGGLE_MY_TRACKS_HELP + }), + + setHelp: (help) => ({ + type: helpActions.SET_HELP, + payload: { + ...help + } + }) +} diff --git a/app/core/help/index.js b/app/core/help/index.js new file mode 100644 index 00000000..7c51cc1d --- /dev/null +++ b/app/core/help/index.js @@ -0,0 +1,4 @@ +export { helpActions } from './actions' +export { helpReducer } from './reducer' +export { helpSagas } from './sagas' +export { getHelp } from './selectors' diff --git a/app/core/help/reducer.js b/app/core/help/reducer.js new file mode 100644 index 00000000..f34cbb0b --- /dev/null +++ b/app/core/help/reducer.js @@ -0,0 +1,36 @@ +import { Record } from 'immutable' + +import { helpActions } from './actions' + +const HelpState = new Record({ + isTracksHelpVisible: false, + isMyTracksHelpVisible: false, + isMyLogsHelpVisible: false +}) + +export function helpReducer (state = new HelpState(), { payload, type }) { + switch (type) { + case helpActions.TOGGLE_TRACKS_HELP: + return state.merge({ + isTracksHelpVisible: !state.isTracksHelpVisible + }) + + case helpActions.TOGGLE_MY_TRACKS_HELP: + return state.merge({ + isMyTracksHelpVisible: !state.isMyTracksHelpVisible + }) + + case helpActions.TOGGLE_MY_LOGS_HELP: + return state.merge({ + isMyLogsHelpVisible: !state.isMyLogsHelpVisible + }) + + case helpActions.SET_HELP: + return state.merge({ + ...payload + }) + + default: + return state + } +} diff --git a/app/core/help/sagas.js b/app/core/help/sagas.js new file mode 100644 index 00000000..c627d4a4 --- /dev/null +++ b/app/core/help/sagas.js @@ -0,0 +1,67 @@ +import { call, fork, take, put, select } from 'redux-saga/effects' + +import { helpActions } from './actions' +import { DEFAULT_HELP_SETTINGS } from '@core/constants' +import { appActions } from '@core/app' +import { helpStorage } from './storage' +import { getHelp } from './selectors' + +export function * saveHelpToStorage () { + const help = yield select(getHelp) + yield call(helpStorage.setPrefs, help) +} + +export function * setHelpFromStorage () { + let help = yield call(helpStorage.getPrefs) + help = Object.assign(DEFAULT_HELP_SETTINGS, help) + if (help) yield put(helpActions.setHelp(help)) +} + +//= ==================================== +// WATCHERS +// ------------------------------------- + +export function * watchInitApp () { + while (true) { + yield take(appActions.INIT_APP) + yield fork(setHelpFromStorage) + } +} + +export function * watchToggleTracksHelp () { + while (true) { + yield take(helpActions.TOGGLE_TRACKS_HELP) + let help = yield select(getHelp) + // persist only when set to not visible + if (!help.isTracksHelpVisible) yield fork(saveHelpToStorage) + } +} + +export function * watchToggleMyTracksHelp () { + while (true) { + yield take(helpActions.TOGGLE_MY_TRACKS_HELP) + let help = yield select(getHelp) + // persist only when set to not visible + if (!help.isMyTracksTrackVisible) yield fork(saveHelpToStorage) + } +} + +export function * watchToggleMyLogsHelp () { + while (true) { + yield take(helpActions.TOGGLE_MY_LOGS_HELP) + let help = yield select(getHelp) + // persist only when set to not visible + if (!help.isMyLogsTrackVisible) yield fork(saveHelpToStorage) + } +} + +//= ==================================== +// ROOT +// ------------------------------------- + +export const helpSagas = [ + fork(watchInitApp), + fork(watchToggleTracksHelp), + fork(watchToggleMyTracksHelp), + fork(watchToggleMyLogsHelp) +] diff --git a/app/core/help/selectors.js b/app/core/help/selectors.js new file mode 100644 index 00000000..f6fccc0a --- /dev/null +++ b/app/core/help/selectors.js @@ -0,0 +1,3 @@ +export function getHelp (state) { + return state.get('help') +} diff --git a/app/core/help/storage.js b/app/core/help/storage.js new file mode 100644 index 00000000..5edddaec --- /dev/null +++ b/app/core/help/storage.js @@ -0,0 +1,16 @@ +import { HELP_STORAGE_KEY } from '@core/constants' +import { localStorageAdapter } from '@core/utils' + +export const helpStorage = { + clear () { + localStorageAdapter.removeItem(HELP_STORAGE_KEY) + }, + + getPrefs () { + return localStorageAdapter.getItem(HELP_STORAGE_KEY) || {} + }, + + setPrefs (prefs) { + localStorageAdapter.setItem(HELP_STORAGE_KEY, prefs) + } +} diff --git a/src/core/history/index.android.js b/app/core/history/index.android.js similarity index 100% rename from src/core/history/index.android.js rename to app/core/history/index.android.js diff --git a/src/core/history/index.ios.js b/app/core/history/index.ios.js similarity index 100% rename from src/core/history/index.ios.js rename to app/core/history/index.ios.js diff --git a/app/core/history/index.js b/app/core/history/index.js new file mode 100644 index 00000000..e192102b --- /dev/null +++ b/app/core/history/index.js @@ -0,0 +1,3 @@ +import { createHashHistory } from 'history' + +export default createHashHistory() diff --git a/src/core/history/index.native.js b/app/core/history/index.native.js similarity index 64% rename from src/core/history/index.native.js rename to app/core/history/index.native.js index 12d9529f..b703121f 100644 --- a/src/core/history/index.native.js +++ b/app/core/history/index.native.js @@ -1,3 +1,3 @@ import createHistory from 'history/createMemoryHistory' -export default createHistory +export default createHistory() diff --git a/app/core/importer/actions.js b/app/core/importer/actions.js new file mode 100644 index 00000000..f7496358 --- /dev/null +++ b/app/core/importer/actions.js @@ -0,0 +1,48 @@ +export const importerActions = { + IMPORTER_STARTING: 'IMPORTER_STARTING', + IMPORTER_FINISHED: 'IMPORTER_FINISHED', + IMPORTER_PROCESSED_FILE: 'IMPORTER_PROCESSED_FILE', + + IMPORTER_ADD: 'IMPORTER_ADD', + + POST_IMPORTER_FAILED: 'POST_IMPORTER_FAILED', + POST_IMPORTER_FULFILLED: 'POST_IMPORTER_FULFILLED', + POST_IMPORTER_PENDING: 'POST_IMPORTER_PENDING', + + postImporterFailed: (address, error) => ({ + type: importerActions.POST_IMPORTER_FAILED, + payload: { + address, + error + } + }), + + postImporterFulfilled: (address, data) => ({ + type: importerActions.POST_IMPORTER_FULFILLED, + payload: { + address, + data + } + }), + + postImporterPending: address => ({ + type: importerActions.POST_IMPORTER_PENDING, + payload: { + address + } + }), + + add: (address, data) => ({ + type: importerActions.IMPORTER_ADD, + payload: { + address, + data + } + }) +} + +export const importerPostActions = { + failed: importerActions.postImporterFailed, + fulfilled: importerActions.postImporterFulfilled, + pending: importerActions.postImporterPending +} diff --git a/app/core/importer/index.js b/app/core/importer/index.js new file mode 100644 index 00000000..db1ec8a5 --- /dev/null +++ b/app/core/importer/index.js @@ -0,0 +1,4 @@ +export { importerActions, importerPostActions } from './actions' +export { importerReducer } from './reducer' +export { importerSagas } from './sagas' +export { getImporterProgress, getImporterFiles } from './selectors' diff --git a/app/core/importer/reducer.js b/app/core/importer/reducer.js new file mode 100644 index 00000000..7dd56da4 --- /dev/null +++ b/app/core/importer/reducer.js @@ -0,0 +1,33 @@ +import { Map, List } from 'immutable' + +import { importerActions } from './actions' + +export const initialState = new Map({ + files: new List(), + errors: new List(), + remaining: 0, + completed: 0, + previouslyCompleted: 0 +}) + +export function importerReducer (state = initialState, { payload, type }) { + switch (type) { + case importerActions.IMPORTER_STARTING: + return state.merge({ + completed: payload.completed, + previouslyCompleted: payload.completed, + remaining: payload.remaining + }) + + case importerActions.IMPORTER_PROCESSED_FILE: + return state.merge({ + files: new List(payload.files), + errors: new List(payload.errors), + completed: payload.completed, + remaining: payload.remaining + }) + + default: + return state + } +} diff --git a/app/core/importer/sagas.js b/app/core/importer/sagas.js new file mode 100644 index 00000000..ee22e140 --- /dev/null +++ b/app/core/importer/sagas.js @@ -0,0 +1,44 @@ +import { fork, takeLatest, put } from 'redux-saga/effects' +import { postImporter } from '@core/api' + +import { notificationActions } from '@core/notifications' +import { importerActions } from './actions' + +export function * importerAdd ({ payload }) { + const { address, data } = payload + yield fork(postImporter, { address, data }) + yield put(notificationActions.show({ + text: 'Starting import', + dismiss: 2000 + })) +} + +export function * postImporterFailed () { + yield put(notificationActions.show({ + text: 'Importer failed', + severity: 'error', + dismiss: 2000 + })) +} + +//= ==================================== +// WATCHERS +// ------------------------------------- + +export function * watchImporterAdd () { + yield takeLatest(importerActions.IMPORTER_ADD, importerAdd) +} + +export function * watchPostImporterFailed () { + yield takeLatest(importerActions.POST_IMPORTER_FAILED, postImporterFailed) +} + +//= ==================================== +// ROOT +// ------------------------------------- + +export const importerSagas = [ + fork(watchImporterAdd), + + fork(watchPostImporterFailed) +] diff --git a/app/core/importer/selectors.js b/app/core/importer/selectors.js new file mode 100644 index 00000000..c14c67f9 --- /dev/null +++ b/app/core/importer/selectors.js @@ -0,0 +1,22 @@ +export function getImporter (state) { + return state.get('importer') +} + +export function getImporterFiles (state) { + const importer = getImporter(state) + const files = importer.get('files') + const errors = importer.get('errors') + return files.concat(errors).toArray() +} + +export function getImporterProgress (state) { + const importer = getImporter(state) + const completed = importer.get('completed') - importer.get('previouslyCompleted') + const remaining = importer.get('remaining') + const total = completed + remaining + return { + remaining, + completed, + total + } +} diff --git a/src/core/info/actions.js b/app/core/info/actions.js similarity index 100% rename from src/core/info/actions.js rename to app/core/info/actions.js diff --git a/src/core/info/index.js b/app/core/info/index.js similarity index 100% rename from src/core/info/index.js rename to app/core/info/index.js diff --git a/app/core/info/reducer.js b/app/core/info/reducer.js new file mode 100644 index 00000000..0596f0a1 --- /dev/null +++ b/app/core/info/reducer.js @@ -0,0 +1,22 @@ +import { Map } from 'immutable' +import { infoActions } from '@core/info' + +export const initialState = new Map({ + subs: new Map(), + bw: new Map(), + repo: new Map() +}) + +export function infoReducer (state = initialState, { payload, type }) { + switch (type) { + case infoActions.INFO_INIT_FULFILLED: + return state.merge({ + subs: new Map(payload.subs), + bw: new Map(payload.bw), + repo: new Map(payload.repo) + }) + + default: + return state + } +} diff --git a/src/core/info/sagas.js b/app/core/info/sagas.js similarity index 100% rename from src/core/info/sagas.js rename to app/core/info/sagas.js diff --git a/src/core/info/selectors.js b/app/core/info/selectors.js similarity index 100% rename from src/core/info/selectors.js rename to app/core/info/selectors.js diff --git a/app/core/listens/actions.js b/app/core/listens/actions.js new file mode 100644 index 00000000..90f7376f --- /dev/null +++ b/app/core/listens/actions.js @@ -0,0 +1,78 @@ +export const listensActions = { + FETCH_LISTENS_PENDING: 'FETCH_LISTENS_PENDING', + FETCH_LISTENS_FULFILLED: 'FETCH_LISTENS_FULFILLED', + FETCH_LISTENS_FAILED: 'FETCH_LISTENS_FAILED', + + POST_LISTEN_PENDING: 'POST_LISTEN_PENDING', + POST_LISTEN_FULFILLED: 'POST_LISTEN_FULFILLED', + POST_LISTEN_FAILED: 'POST_LISTEN_FAILED', + + LOAD_LISTENS: 'LOAD_LISTENS', + LOAD_NEXT_LISTENS: 'LOAD_NEXT_LISTENS', + + loadListens: () => ({ + type: listensActions.LOAD_LISTENS + }), + + loadNextListens: () => ({ + type: listensActions.LOAD_NEXT_LISTENS + }), + + fetchListensRequestFailed: (address, error) => ({ + type: listensActions.FETCH_LISTENS_FAILED, + payload: { + address, + error + } + }), + + fetchListensRequestFulfilled: (address, data) => ({ + type: listensActions.FETCH_LISTENS_FULFILLED, + payload: { + address, + data + } + }), + + fetchListensRequestPending: address => ({ + type: listensActions.FETCH_LISTENS_PENDING, + payload: { + address + } + }), + + postListenFailed: (address, error) => ({ + type: listensActions.POST_LISTEN_FAILED, + payload: { + address, + error + } + }), + + postListenFulfilled: (address, data) => ({ + type: listensActions.POST_LISTEN_FULFILLED, + payload: { + address, + data + } + }), + + postListenPending: address => ({ + type: listensActions.POST_LISTEN_PENDING, + payload: { + address + } + }) +} + +export const listensRequestActions = { + failed: listensActions.fetchListensRequestFailed, + fulfilled: listensActions.fetchListensRequestFulfilled, + pending: listensActions.fetchListensRequestPending +} + +export const listenPostActions = { + failed: listensActions.postListenFailed, + fulfilled: listensActions.postListenFulfilled, + pending: listensActions.postListenPending +} diff --git a/app/core/listens/index.js b/app/core/listens/index.js new file mode 100644 index 00000000..262a6141 --- /dev/null +++ b/app/core/listens/index.js @@ -0,0 +1,7 @@ +export { + listensActions, + listensRequestActions, + listenPostActions +} from './actions' + +export { listensSagas } from './sagas' diff --git a/app/core/listens/sagas.js b/app/core/listens/sagas.js new file mode 100644 index 00000000..83063a2d --- /dev/null +++ b/app/core/listens/sagas.js @@ -0,0 +1,54 @@ +import { call, put, select, fork, takeLatest } from 'redux-saga/effects' + +import { listensActions } from './actions' +import { notificationActions } from '@core/notifications' +import { ITEMS_PER_LOAD } from '@core/constants' +import { getCurrentTracklist } from '@core/tracklists' +import { fetchListens } from '@core/api' + +export function * loadNextListens () { + const tracklist = yield select(getCurrentTracklist) + const start = tracklist.trackIds.size + const params = { start, limit: ITEMS_PER_LOAD } + yield call(fetchListens, { params }) +} + +export function * loadListens () { + const params = { start: 0, limit: ITEMS_PER_LOAD } + yield call(fetchListens, { params }) +} + +export function * postListenFailed () { + yield put(notificationActions.show({ + text: 'Failed to add track to listening history', + severity: 'error', + dismiss: 2000 + })) +} + +//= ==================================== +// WATCHERS +// ------------------------------------- + +export function * watchLoadListens () { + yield takeLatest(listensActions.LOAD_LISTENS, loadListens) +} + +export function * watchLoadNextListens () { + yield takeLatest(listensActions.LOAD_NEXT_LISTENS, loadNextListens) +} + +export function * watchPostListenFailed () { + yield takeLatest(listensActions.POST_LISTEN_FAILED, postListenFailed) +} + +//= ==================================== +// ROOT +// ------------------------------------- + +export const listensSagas = [ + fork(watchLoadListens), + fork(watchLoadNextListens), + + fork(watchPostListenFailed) +] diff --git a/app/core/loglists/actions.js b/app/core/loglists/actions.js new file mode 100644 index 00000000..85b28855 --- /dev/null +++ b/app/core/loglists/actions.js @@ -0,0 +1,206 @@ +export const loglistActions = { + FETCH_LOGS_FAILED: 'FETCH_LOGS_FAILED', + FETCH_LOGS_FULFILLED: 'FETCH_LOGS_FULFILLED', + FETCH_LOGS_PENDING: 'FETCH_LOGS_PENDING', + + LOAD_LOGS: 'LOAD_LOGS', + + POST_LOG_FAILED: 'POST_LOG_FAILED', + POST_LOG_FULFILLED: 'POST_LOG_FULFILLED', + POST_LOG_PENDING: 'POST_LOG_PENDING', + + LINK_LOG: 'LINK_LOG', + + DELETE_LOG_LINK_FAILED: 'DELETE_LOG_LINK_FAILED', + DELETE_LOG_LINK_FULFILLED: 'DELETE_LOG_LINK_FULFILLED', + DELETE_LOG_LINK_PENDING: 'DELETE_LOG_LINK_PENDING', + + UNLINK_LOG: 'UNLINK_LOG', + + LOAD_ALL_LOGS: 'LOAD_ALL_LOGS', + + FETCH_ALL_LOGS_FAILED: 'FETCH_ALL_LOGS_FAILED', + FETCH_ALL_LOGS_FULFILLED: 'FETCH_ALL_LOGS_FULFILLED', + FETCH_ALL_LOGS_PENDING: 'FETCH_ALL_LOGS_PENDING', + + LOAD_PEER_LOGS: 'LOAD_PEER_LOGS', + + FETCH_PEER_LOGS_FAILED: 'FETCH_PEER_LOGS_FAILED', + FETCH_PEER_LOGS_FULFILLED: 'FETCH_PEER_LOGS_FULFILLED', + FETCH_PEER_LOGS_PENDING: 'FETCH_PEER_LOGS_PENDING', + + fetchLogsFailed: (address, error) => ({ + type: loglistActions.FETCH_LOGS_FAILED, + payload: { + address, + error + } + }), + + fetchLogsFulfilled: (address, data) => ({ + type: loglistActions.FETCH_LOGS_FULFILLED, + payload: { + data, + address + } + }), + + fetchLogsPending: address => ({ + type: loglistActions.FETCH_LOGS_PENDING, + payload: { + address + } + }), + + loadLogs: address => ({ + type: loglistActions.LOAD_LOGS, + payload: { + address + } + }), + + postLogFailed: (address, error) => ({ + type: loglistActions.POST_LOG_FAILED, + payload: { + address, + error + } + }), + + postLogFulfilled: (address, data) => ({ + type: loglistActions.POST_LOG_FULFILLED, + payload: { + address, + data + } + }), + + postLogPending: address => ({ + type: loglistActions.POST_LOG_PENDING, + payload: { + address + } + }), + + linkLog: (address, data) => ({ + type: loglistActions.LINK_LOG, + payload: { + address, + data + } + }), + + unlinkLog: (address) => ({ + type: loglistActions.UNLINK_LOG, + payload: { + address + } + }), + + deleteLogLinkFailed: (address, error) => ({ + type: loglistActions.DELETE_LOG_LINK_FAILED, + payload: { + address, + error + } + }), + + deleteLogLinkFulfilled: (address, data) => ({ + type: loglistActions.DELETE_LOG_LINK_FULFILLED, + payload: { + address, + data + } + }), + + deleteLogLinkPending: address => ({ + type: loglistActions.DELETE_LOG_LINK_PENDING, + payload: { + address + } + }), + + loadAllLogs: () => ({ + type: loglistActions.LOAD_ALL_LOGS + }), + + fetchAllLogsFailed: (address, error) => ({ + type: loglistActions.FETCH_ALL_LOGS_FAILED, + payload: { + address, + error + } + }), + + fetchAllLogsFulfilled: (address, data) => ({ + type: loglistActions.FETCH_ALL_LOGS_FULFILLED, + payload: { + data, + address + } + }), + + fetchAllLogsPending: address => ({ + type: loglistActions.FETCH_ALL_LOGS_PENDING, + payload: { + address + } + }), + + loadPeerLogs: () => ({ + type: loglistActions.LOAD_PEER_LOGS + }), + + fetchPeerLogsFailed: (address, error) => ({ + type: loglistActions.FETCH_PEER_LOGS_FAILED, + payload: { + address, + error + } + }), + + fetchPeerLogsFulfilled: (address, data) => ({ + type: loglistActions.FETCH_PEER_LOGS_FULFILLED, + payload: { + data, + address + } + }), + + fetchPeerLogsPending: address => ({ + type: loglistActions.FETCH_PEER_LOGS_PENDING, + payload: { + address + } + }) +} + +export const loglistRequestActions = { + failed: loglistActions.fetchLogsFailed, + fulfilled: loglistActions.fetchLogsFulfilled, + pending: loglistActions.fetchLogsPending +} + +export const loglistPostActions = { + failed: loglistActions.postLogFailed, + fulfilled: loglistActions.postLogFulfilled, + pending: loglistActions.postLogPending +} + +export const loglistDeleteActions = { + failed: loglistActions.deleteLogLinkFailed, + fulfilled: loglistActions.deleteLogLinkFulfilled, + pending: loglistActions.deleteLogLinkPending +} + +export const allLoglistRequestActions = { + failed: loglistActions.fetchAllLogsFailed, + fulfilled: loglistActions.fetchAllLogsFulfilled, + pending: loglistActions.fetchAllLogsPending +} + +export const peerLoglistRequestActions = { + failed: loglistActions.fetchPeerLogsFailed, + fulfilled: loglistActions.fetchPeerLogsFulfilled, + pending: loglistActions.fetchPeerLogsPending +} diff --git a/app/core/loglists/index.js b/app/core/loglists/index.js new file mode 100644 index 00000000..4423fcb7 --- /dev/null +++ b/app/core/loglists/index.js @@ -0,0 +1,22 @@ +export { + loglistActions, + loglistRequestActions, + loglistPostActions, + loglistDeleteActions, + allLoglistRequestActions, + peerLoglistRequestActions +} from './actions' + +export { loglistSagas } from './sagas' + +export { + getCurrentLoglist, + getCurrentLoglistLog, + getPeerLoglist, + getAllLoglist, + getLogsForCurrentLoglist, + getLogsForPeerLoglist, + getLogsForAllLoglist +} from './selectors' + +export { loglistsReducer } from './loglists-reducer' diff --git a/app/core/loglists/loglist-reducer.js b/app/core/loglists/loglist-reducer.js new file mode 100644 index 00000000..d3a9a54e --- /dev/null +++ b/app/core/loglists/loglist-reducer.js @@ -0,0 +1,41 @@ +import { + PEER_LOGLIST_ADDRESS, + ALL_LOGLIST_ADDRESS +} from '@core/constants' +import { loglistActions } from './actions' +import { logActions } from '@core/logs' +import { Loglist } from './loglist' +import { mergeList } from '@core/utils' + +export function loglistReducer (state = new Loglist(), {payload, type}) { + switch (type) { + case loglistActions.FETCH_LOGS_FULFILLED: + case loglistActions.FETCH_PEER_LOGS_FULFILLED: + case loglistActions.FETCH_ALL_LOGS_FULFILLED: + case logActions.LOG_LOADED: + const data = Array.isArray(payload.data) ? payload.data : [payload.data] + return state.withMutations(loglist => { + loglist.merge({ + isPending: false, + addresses: mergeList(loglist.addresses, data, 'content.address') + }) + }) + + case loglistActions.FETCH_LOGS_PENDING: + case loglistActions.FETCH_PEER_LOGS_PENDING: + case loglistActions.FETCH_ALL_LOGS_PENDING: + return state.set('isPending', true) + + case loglistActions.LOAD_LOGS: + return state.set('address', payload.address) + + case loglistActions.LOAD_PEER_LOGS: + return state.set('address', PEER_LOGLIST_ADDRESS) + + case loglistActions.LOAD_ALL_LOGS: + return state.set('address', ALL_LOGLIST_ADDRESS) + + default: + return state + } +} diff --git a/app/core/loglists/loglist.js b/app/core/loglists/loglist.js new file mode 100644 index 00000000..78a59dd2 --- /dev/null +++ b/app/core/loglists/loglist.js @@ -0,0 +1,8 @@ +import { List, Record } from 'immutable' + +export const Loglist = new Record({ + address: null, + isPending: false, + isUpdating: false, + addresses: new List() +}) diff --git a/app/core/loglists/loglists-reducer.js b/app/core/loglists/loglists-reducer.js new file mode 100644 index 00000000..c1315e9b --- /dev/null +++ b/app/core/loglists/loglists-reducer.js @@ -0,0 +1,62 @@ +import { Map } from 'immutable' + +import { + PEER_LOGLIST_ADDRESS, + ALL_LOGLIST_ADDRESS +} from '@core/constants' +import { loglistActions } from './actions' +import { loglistReducer } from './loglist-reducer' +import { logActions } from '@core/logs' + +export const initialState = new Map({ + currentLoglistAddress: null +}) + +export function loglistsReducer (state = initialState, action) { + const { payload } = action + + switch (action.type) { + case loglistActions.FETCH_LOGS_FULFILLED: + case loglistActions.FETCH_LOGS_PENDING: + case loglistActions.FETCH_PEER_LOGS_PENDING: + case loglistActions.FETCH_PEER_LOGS_FULFILLED: + case loglistActions.FETCH_ALL_LOGS_PENDING: + case loglistActions.FETCH_ALL_LOGS_FULFILLED: + return state.set( + payload.address, + loglistReducer(state.get(payload.address), action) + ) + + case logActions.LOG_LOADED: + return state.set( + ALL_LOGLIST_ADDRESS, + loglistReducer(state.get(ALL_LOGLIST_ADDRESS), action) + ) + + case loglistActions.POST_LOG_FAILED: + case loglistActions.POST_LOG_FULFILLED: + return state.setIn([payload.address, 'isUpdating'], false) + + case loglistActions.POST_LOG_PENDING: + return state.setIn([payload.address, 'isUpdating'], true) + + case loglistActions.LOAD_LOGS: + return state.merge({ + currentLoglistAddress: payload.address, + [payload.address]: loglistReducer(undefined, action) + }) + + case loglistActions.LOAD_PEER_LOGS: + return state.merge({ + [PEER_LOGLIST_ADDRESS]: loglistReducer(undefined, action) + }) + + case loglistActions.LOAD_ALL_LOGS: + return state.merge({ + [ALL_LOGLIST_ADDRESS]: loglistReducer(undefined, action) + }) + + default: + return state + } +} diff --git a/app/core/loglists/sagas.js b/app/core/loglists/sagas.js new file mode 100644 index 00000000..dc2c4243 --- /dev/null +++ b/app/core/loglists/sagas.js @@ -0,0 +1,99 @@ +import { call, fork, takeLatest, put } from 'redux-saga/effects' + +import { + PEER_LOGLIST_ADDRESS, + ALL_LOGLIST_ADDRESS +} from '@core/constants' +import { + fetchLogs, + fetchPeers, + fetchAllLogs, + postLogLink, + deleteLogLink +} from '@core/api' +import { notificationActions } from '@core/notifications' +import { loglistActions } from './actions' +import history from '@core/history' + +export function * loadLogs ({ payload }) { + const { address } = payload + yield call(fetchLogs, { address }) +} + +export function * loadPeerLogs () { + yield call(fetchPeers, { address: PEER_LOGLIST_ADDRESS }) +} + +export function * loadAllLogs () { + yield call(fetchAllLogs, { address: ALL_LOGLIST_ADDRESS }) +} + +export function * linkLog ({ payload }) { + const { address, data } = payload + yield fork(postLogLink, { address, data }) + + if (history.location.pathname.includes('/link-log')) { + yield call(history.back) + } +} + +export function * unlinkLog ({ payload }) { + const { address } = payload + const data = { linkAddress: address } + yield call(deleteLogLink, { address, data }) +} + +export function * postLogFailed () { + yield put(notificationActions.show({ + text: 'Failed to link library', + severity: 'error', + dismiss: 2000 + })) +} + +export function * deleteLogLinkFailed () { + yield put(notificationActions.show({ + text: 'Failed to remove library', + severity: 'error', + dismiss: 2000 + })) +} + +export function * watchLoadLogs () { + yield takeLatest(loglistActions.LOAD_LOGS, loadLogs) +} + +export function * watchLinkLog () { + yield takeLatest(loglistActions.LINK_LOG, linkLog) +} + +export function * watchUnlinkLog () { + yield takeLatest(loglistActions.UNLINK_LOG, unlinkLog) +} + +export function * watchLoadPeerLogs () { + yield takeLatest(loglistActions.LOAD_PEER_LOGS, loadPeerLogs) +} + +export function * watchLoadAllLogs () { + yield takeLatest(loglistActions.LOAD_ALL_LOGS, loadAllLogs) +} + +export function * watchDeleteLogLinkFailed () { + yield takeLatest(loglistActions.DELETE_LOG_LINK_FAILED, deleteLogLinkFailed) +} + +export function * watchPostLogFailed () { + yield takeLatest(loglistActions.POST_LOG_FAILED, postLogFailed) +} + +export const loglistSagas = [ + fork(watchLoadLogs), + fork(watchLinkLog), + fork(watchUnlinkLog), + fork(watchLoadPeerLogs), + fork(watchLoadAllLogs), + + fork(watchPostLogFailed), + fork(watchDeleteLogLinkFailed) +] diff --git a/app/core/loglists/selectors.js b/app/core/loglists/selectors.js new file mode 100644 index 00000000..dba1b2da --- /dev/null +++ b/app/core/loglists/selectors.js @@ -0,0 +1,83 @@ +import { createSelector } from 'reselect' + +import { + PEER_LOGLIST_ADDRESS, + ALL_LOGLIST_ADDRESS +} from '@core/constants' +import { getLogs, getLogByAddress, Log } from '@core/logs' +import { Loglist } from './loglist' + +export function getLoglists (state) { + return state.get('loglists') +} + +export function getLoglistByAddress (state, address) { + return getLoglists(state).get(address) +} + +export function getCurrentLoglistAddress (state) { + let loglists = getLoglists(state) + return loglists.get('currentLoglistAddress') +} + +export function getCurrentLoglist (state) { + let loglists = getLoglists(state) + return loglists.get(loglists.get('currentLoglistAddress')) || new Loglist() +} + +export function getCurrentLoglistLog (state) { + const address = getCurrentLoglistAddress(state) + if (!address) { + return new Log() + } + return getLogByAddress(state, address) +} + +export function getPeerLoglist (state) { + let loglists = getLoglists(state) + return loglists.get(PEER_LOGLIST_ADDRESS) || new Loglist() +} + +export function getAllLoglist (state) { + let loglists = getLoglists(state) + return loglists.get(ALL_LOGLIST_ADDRESS) || new Loglist() +} + +export const getCurrentAddresses = createSelector( + getCurrentLoglist, + loglist => loglist.addresses +) + +export const getPeerAddresses = createSelector( + getPeerLoglist, + loglist => loglist.addresses +) + +export const getAllAddresses = createSelector( + getAllLoglist, + loglist => loglist.addresses +) + +export const getLogsForCurrentLoglist = createSelector( + getCurrentAddresses, + getLogs, + (addresses, logs) => { + return addresses.map(address => logs.get(address)) + } +) + +export const getLogsForPeerLoglist = createSelector( + getPeerAddresses, + getLogs, + (addresses, logs) => { + return addresses.map(address => logs.get(address)) + } +) + +export const getLogsForAllLoglist = createSelector( + getAllAddresses, + getLogs, + (addresses, logs) => { + return addresses.map(address => logs.get(address)) + } +) diff --git a/app/core/logs/actions.js b/app/core/logs/actions.js new file mode 100644 index 00000000..6bd8b416 --- /dev/null +++ b/app/core/logs/actions.js @@ -0,0 +1,163 @@ +export const logActions = { + FETCH_LOG_FAILED: 'FETCH_LOG_FAILED', + FETCH_LOG_FULFILLED: 'FETCH_LOG_FULFILLED', + FETCH_LOG_PENDING: 'FETCH_LOG_PENDING', + + CONNECT_LOG_FAILED: 'CONNECT_LOG_FAILED', + CONNECT_LOG_FULFILLED: 'CONNECT_LOG_FULFILLED', + CONNECT_LOG_PENDING: 'CONNECT_LOG_PENDING', + + DISCONNECT_LOG_FAILED: 'DISCONNECT_LOG_FAILED', + DISCONNECT_LOG_FULFILLED: 'DISCONNECT_LOG_FULFILLED', + DISCONNECT_LOG_PENDING: 'DISCONNECT_LOG_PENDING', + + LOAD_LOG: 'LOAD_LOG', + + LOG_LOADED: 'LOG_LOADED', + LOG_LOADING: 'LOG_LOADING', + + CONNECT_LOG: 'CONNECT_LOG', + DISCONNECT_LOG: 'DISCONNECT_LOG', + + LOG_CONNECTED: 'LOG_CONNECTED', + LOG_DISCONNECTED: 'LOG_DISCONNECTED', + + LOG_REPLICATED: 'LOG_REPLICATED', + LOG_REPLICATE_PROGRESS: 'LOG_REPLICATE_PROGRESS', + + RECORD_PEER_LEFT: 'RECORD_PEER_LEFT', + RECORD_PEER_JOINED: 'RECORD_PEER_JOINED', + LOG_PEER_JOINED: 'LOG_PEER_JOINED', + + LOG_INDEX_UPDATED: 'LOG_INDEX_UPDATED', + + DELETE_LOG: 'DELETE_LOG', + + DELETE_LOG_PENDING: 'DELETE_LOG_PENDING', + DELETE_LOG_FAILED: 'DELETE_LOG_FAILED', + DELETE_LOG_FULFILLED: 'DELETE_LOG_FULFILLED', + + connectLogFailed: (address, error) => ({ + type: logActions.CONNECT_LOG_FAILED, + payload: { address, error } + }), + + connectLogPending: address => ({ + type: logActions.CONNECT_LOG_PENDING, + payload: { address } + }), + + connectLogFulfilled: (address, data) => ({ + type: logActions.CONNECT_LOG_FULFILLED, + payload: { address, data } + }), + + disconnectLogFailed: (address, error) => ({ + type: logActions.DISCONNECT_LOG_FAILED, + payload: { address, error } + }), + + disconnectLogPending: address => ({ + type: logActions.DISCONNECT_LOG_PENDING, + payload: { address } + }), + + disconnectLogFulfilled: (address, data) => ({ + type: logActions.DISCONNECT_LOG_FULFILLED, + payload: { address, data } + }), + + fetchLogFailed: (address, error) => ({ + type: logActions.FETCH_LOG_FAILED, + payload: { + address, + error + } + }), + + fetchLogFulfilled: (address, data) => ({ + type: logActions.FETCH_LOG_FULFILLED, + payload: { + data, + address + } + }), + + fetchLogPending: address => ({ + type: logActions.FETCH_LOG_PENDING, + payload: { + address + } + }), + + connectLog: (address) => ({ + type: logActions.CONNECT_LOG, + payload: { address } + }), + + disconnectLog: (address) => ({ + type: logActions.DISCONNECT_LOG, + payload: { address } + }), + + loadLog: address => ({ + type: logActions.LOAD_LOG, + payload: { + address + } + }), + + deleteLog: (address) => ({ + type: logActions.DELETE_LOG, + payload: { + address + } + }), + + deleteLogFailed: (address, error) => ({ + type: logActions.DELETE_LOG_FAILED, + payload: { + address, + error + } + }), + + deleteLogPending: address => ({ + type: logActions.DELETE_LOG_PENDING, + payload: { + address + } + }), + + deleteLogFulfilled: (address, data) => ({ + type: logActions.DELETE_LOG_FULFILLED, + payload: { + address, + data + } + }) +} + +export const logRequestActions = { + failed: logActions.fetchLogFailed, + fulfilled: logActions.fetchLogFulfilled, + pending: logActions.fetchLogPending +} + +export const logConnectActions = { + failed: logActions.connectLogFailed, + fulfilled: logActions.connectLogFulfilled, + pending: logActions.connectLogPending +} + +export const logDisconnectActions = { + failed: logActions.disconnectLogFailed, + fulfilled: logActions.disconnectLogFulfilled, + pending: logActions.disconnectLogPending +} + +export const logDeleteActions = { + failed: logActions.deleteLogFailed, + fulfilled: logActions.deleteLogFulfilled, + pending: logActions.deleteLogPending +} diff --git a/app/core/logs/index.js b/app/core/logs/index.js new file mode 100644 index 00000000..56907477 --- /dev/null +++ b/app/core/logs/index.js @@ -0,0 +1,18 @@ +export { + getLogs, + getAllLogs, + getLogByAddress, + getMyLog, + getReplicationProgress, + getAllPeers +} from './selectors' +export { + logActions, + logDeleteActions, + logConnectActions, + logDisconnectActions, + logRequestActions +} from './actions' +export { logsSagas } from './sagas' +export { logsReducer } from './reducer' +export { Log, createLog } from './log' diff --git a/app/core/logs/log.js b/app/core/logs/log.js new file mode 100644 index 00000000..ac188572 --- /dev/null +++ b/app/core/logs/log.js @@ -0,0 +1,81 @@ +import hashicon from 'hashicon' +import { Record, List } from 'immutable' + +const getShortAddress = (address) => { + const parts = address.toString() + .split('/') + .filter((e, i) => !((i === 0 || i === 1) && address.toString().indexOf('/orbit') === 0 && e === 'orbitdb')) + .filter(e => e !== '' && e !== ' ') + + const multihash = parts[0] + return `${multihash.slice(0, 5)}...${multihash.slice(-5)}` +} + +const generateAvatar = (id) => { + const opts = { size: 100 } + const icon = hashicon(id, opts) + return icon.toDataURL() +} + +export const Log = new Record({ + id: null, + address: null, + alias: null, + avatar: null, + name: null, + logName: null, + displayName: null, + shortAddress: null, + location: null, + bio: null, + isLinked: false, + isReplicating: false, + isLoadingIndex: false, + isProcessingIndex: false, + latestHeadTimestamp: null, + processingCount: 0, + peers: new List(), + isUpdating: false, + trackCount: 0, + logCount: 0, + max: 0, + length: 0, + isMe: false +}) + +export function createLog (data) { + if (!data.content) { + return + } + + const shortAddress = getShortAddress(data.content.address) + const displayName = data.content.alias || data.content.name || shortAddress + const latestHead = data.heads.sort((a, b) => b.timestamp - a.timestamp)[0] + const logName = data.isMe + ? (data.content.name || shortAddress) + : (displayName) + + return new Log({ + logName, + shortAddress, + displayName, + id: data.id, + latestHeadTimestamp: latestHead ? latestHead.payload.value.timestamp : null, + address: data.content.address, + alias: data.content.alias, + avatar: data.content.avatar || data.avatar || generateAvatar(data.content.address), + name: data.content.name, + location: data.content.location, + bio: data.content.bio, + peers: new List(data.peers), + isLinked: !!data.isLinked, + isMe: !!data.isMe, + isReplicating: !!data.isReplicating, + isLoadingIndex: !!data.isLoadingIndex, + isProcessingIndex: !!data.isProcessingIndex, + trackCount: data.trackCount, + logCount: data.logCount, + max: Math.max(data.heads.length && data.heads[0].clock.time, data.replicationStatus.max, 0), + length: data.length + }) +} diff --git a/app/core/logs/reducer.js b/app/core/logs/reducer.js new file mode 100644 index 00000000..823892e0 --- /dev/null +++ b/app/core/logs/reducer.js @@ -0,0 +1,142 @@ +import { Map, List } from 'immutable' + +import { loglistActions } from '@core/loglists' +import { logActions } from './actions' +import { createLog } from './log' +import { aboutActions } from '@core/about' + +export function logsReducer (state = new Map(), {payload, type}) { + switch (type) { + case loglistActions.FETCH_LOGS_FULFILLED: + case loglistActions.FETCH_PEER_LOGS_FULFILLED: + case loglistActions.FETCH_ALL_LOGS_FULFILLED: + return state.withMutations(logs => { + payload.data.forEach(logData => { + logs.set(logData.content.address, createLog(logData)) + }) + }) + + case logActions.LOG_LOADED: + case logActions.LOG_LOADING: + case logActions.FETCH_LOG_FULFILLED: + return state.withMutations(logs => { + logs.set(payload.data.content.address, createLog(payload.data)) + }) + + case logActions.LOG_REPLICATED: + return state.withMutations(logs => { + const log = logs.get(payload.address) + if (log) { + logs.setIn([payload.address, 'length'], payload.length) + if (payload.replicationStatus.max > log.max) { + logs.setIn([payload.address, 'max'], payload.replicationStatus.max) + } + } + }) + + case logActions.LOG_REPLICATE_PROGRESS: + return state.withMutations(logs => { + const log = logs.get(payload.address) + if (log) { + logs.setIn([payload.address, 'length'], payload.length) + if (payload.replicationStatus.max > log.max) { + logs.setIn([payload.address, 'max'], payload.replicationStatus.max) + } + } + }) + + case logActions.LOG_CONNECTED: + return state.withMutations(logs => { + const log = logs.get(payload.address) + if (log) { + logs.setIn([payload.address, 'isReplicating'], true) + } + }) + + case logActions.LOG_DISCONNECTED: + return state.withMutations(logs => { + const log = logs.get(payload.address) + if (log) { + logs.setIn([payload.address, 'isReplicating'], false) + } + }) + + case loglistActions.DELETE_LOG_LINK_FULFILLED: + return state.withMutations(logs => { + logs.setIn([payload.data.linkAddress, 'isLinked'], false) + logs.setIn([payload.data.linkAddress, 'isUpdating'], false) + }) + + case logActions.CONNECT_LOG_PENDING: + case logActions.DISCONNECT_LOG_PENDING: + case loglistActions.DELETE_LOG_LINK_PENDING: + case loglistActions.POST_LOG_PENDING: + return state.withMutations(logs => { + logs.setIn([payload.address, 'isUpdating'], true) + }) + + case loglistActions.POST_LOG_FULFILLED: + return state.withMutations(logs => { + logs.setIn([payload.address, 'isUpdating'], true) + logs.mergeIn([payload.data.content.address], createLog(payload.data)) + }) + + case logActions.DISCONNECT_LOG_FAILED: + case logActions.DISCONNECT_LOG_FULFILLED: + case logActions.CONNECT_LOG_FULFILLED: + case logActions.CONNECT_LOG_FAILED: + case loglistActions.POST_LOG_FAILED: + case loglistActions.DELETE_LOG_LINK_FAILED: + return state.withMutations(logs => { + logs.setIn([payload.address, 'isUpdating'], false) + }) + + case logActions.RECORD_PEER_JOINED: + case logActions.LOG_PEER_JOINED: + return state.withMutations(logs => { + const log = logs.get(payload.address) + if (log) { + logs.setIn([payload.address, 'peers'], mergePeers(log.peers, [payload.peerId])) + } + }) + + case logActions.RECORD_PEER_LEFT: + return state.withMutations(logs => { + logs.map((log) => { + const idx = log.peers.indexOf(payload.peerId) + if (idx > 0) log.peers.delete(idx) + }) + }) + + case logActions.LOG_INDEX_UPDATED: + if (!state.get(payload.address)) { + return state + } + + const data = JSON.parse(JSON.stringify(payload)) + const { isProcessingIndex, processingCount } = data + const item = { isProcessingIndex, processingCount } + if (data.trackCount) item.trackCount = data.trackCount + if (data.logCount) item.logCount = data.logCount + + return state.withMutations(logs => { + logs.mergeIn([payload.address], item) + }) + + case aboutActions.POST_ABOUT_FULFILLED: + return state.mergeIn([payload.address], createLog(payload.data)) + + default: + return state + } +} + +function mergePeers (peerList, collection) { + let peers = peerList.toJS() + let newPeers = collection.reduce((list, peer) => { + if (peers.indexOf(peer) === -1) list.push(peer) + return list + }, []) + + return newPeers.length ? new List(peers.concat(newPeers)) : peerList +} diff --git a/app/core/logs/sagas.js b/app/core/logs/sagas.js new file mode 100644 index 00000000..ea3367ed --- /dev/null +++ b/app/core/logs/sagas.js @@ -0,0 +1,101 @@ +import { call, put, fork, takeLatest, takeEvery } from 'redux-saga/effects' + +import { logActions } from './actions' +import { + fetchLog, + deleteLog, + requestConnectLog, + requestDisconnectLog +} from '@core/api' +import { notificationActions } from '@core/notifications' + +export function * removeLog ({ payload }) { + const { address } = payload + yield call(deleteLog, { address }) +} + +export function * loadLog ({ payload = {} }) { + const { address } = payload + yield call(fetchLog, { address }) +} + +export function * connectLog ({ payload }) { + const { address } = payload + yield call(requestConnectLog, { address }) +} + +export function * disconnectLog ({ payload }) { + const { address } = payload + yield call(requestDisconnectLog, { address }) +} + +export function * deleteLogFailed () { + yield put(notificationActions.show({ + text: 'Failed to delete library', + severity: 'error', + dismiss: 2000 + })) +} + +export function * connectLogFailed () { + yield put(notificationActions.show({ + text: 'Could not connect to library', + severity: 'error', + dismiss: 2000 + })) +} + +export function * disconnectLogFailed () { + yield put(notificationActions.show({ + text: 'Could not disconnect from library', + severity: 'error', + dismiss: 2000 + })) +} + +//= ==================================== +// WATCHERS +// ------------------------------------- + +export function * watchDeleteLog () { + yield takeLatest(logActions.DELETE_LOG, removeLog) +} + +export function * watchLoadLog () { + yield takeEvery(logActions.LOAD_LOG, loadLog) +} + +export function * watchConnectLog () { + yield takeLatest(logActions.CONNECT_LOG, connectLog) +} + +export function * watchDisconnectLog () { + yield takeLatest(logActions.DISCONNECT_LOG, disconnectLog) +} + +export function * watchDeleteLogFailed () { + yield takeLatest(logActions.DELETE_LOG_FAILED, deleteLogFailed) +} + +export function * watchConnectLogFailed () { + yield takeLatest(logActions.CONNECT_LOG_FAILED, connectLogFailed) +} + +export function * watchDisconnectLogFailed () { + yield takeLatest(logActions.DISCONNECT_LOG_FAILED, disconnectLogFailed) +} + +//= ==================================== +// ROOT +// ------------------------------------- + +export const logsSagas = [ + fork(watchDeleteLog), + fork(watchConnectLog), + fork(watchDisconnectLog), + fork(watchLoadLog), + + fork(watchDeleteLogFailed), + fork(watchConnectLogFailed), + fork(watchDisconnectLogFailed) +] diff --git a/app/core/logs/selectors.js b/app/core/logs/selectors.js new file mode 100644 index 00000000..f657ca4c --- /dev/null +++ b/app/core/logs/selectors.js @@ -0,0 +1,41 @@ +export function getLogs (state) { + return state.get('logs') +} + +export function getAllLogs (state) { + const logs = getLogs(state) + return logs.map(v => v).toList() +} + +export function getLogByAddress (state, address) { + return getLogs(state).get(address) +} + +export function getMyLog (state) { + const address = state.get('app').get('address') + return getLogByAddress(state, address) +} + +export function getAllPeers (state) { + const logs = getLogs(state) + const peers = logs.map(v => v.peers).toList().toJS().flat() + const dedupPeers = Array.from(new Set(peers)) + return dedupPeers +} + +export function getReplicationProgress (state) { + const logs = getLogs(state) + let result = { + progress: 0, + total: 0 + + } + for (const log of logs.values()) { + const length = log.get('length') + const max = log.get('max') + result.progress += length + result.total += Math.max(max, length) + } + + return result +} diff --git a/app/core/notifications/actions.js b/app/core/notifications/actions.js new file mode 100644 index 00000000..bb148cd6 --- /dev/null +++ b/app/core/notifications/actions.js @@ -0,0 +1,13 @@ +export const notificationActions = { + SHOW_NOTIFICATION: 'SHOW_NOTIFICATION', + + show: ({ text, severity, action, dismiss }) => ({ + type: notificationActions.SHOW_NOTIFICATION, + payload: { + text, + severity, + action, + dismiss + } + }) +} diff --git a/app/core/notifications/index.js b/app/core/notifications/index.js new file mode 100644 index 00000000..3de5d62c --- /dev/null +++ b/app/core/notifications/index.js @@ -0,0 +1,6 @@ +export { notificationReducer } from './reducer' +export { notificationSagas } from './sagas' +export { notificationActions } from './actions' +export { + getNotification +} from './selectors' diff --git a/app/core/notifications/reducer.js b/app/core/notifications/reducer.js new file mode 100644 index 00000000..032a50dc --- /dev/null +++ b/app/core/notifications/reducer.js @@ -0,0 +1,21 @@ +import { Record } from 'immutable' + +import { notificationActions } from './actions' + +export const initialState = new Record({ + text: null, + severity: null, + key: null, + action: null, + dismiss: null +}) + +export function notificationReducer (state = initialState(), { payload, type }) { + switch (type) { + case notificationActions.SHOW_NOTIFICATION: + return state.merge({ key: new Date().getTime(), ...payload }) + + default: + return state + } +} diff --git a/app/core/notifications/sagas.js b/app/core/notifications/sagas.js new file mode 100644 index 00000000..1aca1739 --- /dev/null +++ b/app/core/notifications/sagas.js @@ -0,0 +1,84 @@ +import { fork, take, select, put, takeLatest } from 'redux-saga/effects' +import { push } from 'react-router-redux' + +import { getApp } from '@core/app' +import { importerActions } from '@core/importer' +import { trackActions } from '@core/tracks' +import { notificationActions } from './actions' +import { tracklistActions, getCurrentTracklist } from '@core/tracklists' +import { logActions } from '@core/logs' + +export function * importerFinished () { + const app = yield select(getApp) + yield put(notificationActions.show({ + text: 'Import finished', + action: { + text: 'Go to tracks', + onclick: () => push(`/tracks${app.address}`) + } + })) +} + +export function * trackAdded ({ data }) { + const app = yield select(getApp) + yield put(notificationActions.show({ + text: 'Track Added', + action: { + text: 'Go to tracks', + onclick: () => push(`/tracks${app.address}`) + } + })) +} + +export function * updateTracklist () { + const tracklist = yield select(getCurrentTracklist) + if (!tracklist.isOutdated) { + return + } + + const action = tracklistActions.loadTracks({ ...tracklist.toJS() }) + + if (!tracklist.isPending && !tracklist.trackIds.size) { + yield put(action) + } else { + yield put(notificationActions.show({ + text: 'Library updated', + action: { + text: 'Refresh', + onclick: () => action + } + })) + } +} + +//= ==================================== +// WATCHERS +// ------------------------------------- + +export function * watchImporterFinished () { + while (true) { + yield take(importerActions.IMPORTER_FINISHED) + yield fork(importerFinished) + } +} + +export function * watchTrackAdded () { + while (true) { + const { payload } = yield take(trackActions.TRACK_ADDED) + yield fork(trackAdded, payload) + } +} + +export function * watchTracklistOutdated () { + yield takeLatest(logActions.LOG_INDEX_UPDATED, updateTracklist) +} + +//= ==================================== +// ROOT +// ------------------------------------- + +export const notificationSagas = [ + fork(watchImporterFinished), + fork(watchTrackAdded), + fork(watchTracklistOutdated) +] diff --git a/app/core/notifications/selectors.js b/app/core/notifications/selectors.js new file mode 100644 index 00000000..699dd9db --- /dev/null +++ b/app/core/notifications/selectors.js @@ -0,0 +1,3 @@ +export function getNotification (state) { + return state.get('notification') +} diff --git a/app/core/player/actions.js b/app/core/player/actions.js new file mode 100644 index 00000000..d50ef167 --- /dev/null +++ b/app/core/player/actions.js @@ -0,0 +1,249 @@ +export const playerActions = { + AUDIO_ENDED: 'AUDIO_ENDED', + AUDIO_PAUSED: 'AUDIO_PAUSED', + AUDIO_CANCELLED: 'AUDIO_CANCELLED', + AUDIO_PLAYING: 'AUDIO_PLAYING', + AUDIO_TIME_UPDATED: 'AUDIO_TIME_UPDATED', + AUDIO_VOLUME_CHANGED: 'AUDIO_VOLUME_CHANGED', + + PLAY_TRACK: 'PLAY_TRACK', + PLAY_QUEUE_TRACK: 'PLAY_QUEUE_TRACK', + PLAY_PLAYER_TRACKLIST_TRACK: 'PLAY_PLAYER_TRACKLIST_TRACK', + PLAY_TRACKLIST: 'PLAY_TRACKLIST', + PLAY_SELECTED_TRACK: 'PLAY_SELECTED_TRACK', + PLAY_PREVIOUS: 'PLAY_PREVIOUS', + PLAY_NEXT: 'PLAY_NEXT', + + SHUFFLE_SELECTED_TRACKLIST: 'SHUFFLE_SELECTED_TRACKLIST', + SHUFFLE_TRACKLIST: 'SHUFFLE_TRACKLIST', + STOP_SHUFFLE: 'STOP_SHUFFLE', + + QUEUE_TRACK: 'QUEUE_TRACK', + UNQUEUE_TRACK: 'UNQUEUE_TRACK', + REORDER_QUEUE: 'REORDER_QUEUE', + CLEAR_QUEUE: 'CLEAR_QUEUE', + TOGGLE_QUEUE: 'TOGGLE_QUEUE', + TOGGLE_PLAY_REPEAT: 'TOGGLE_PLAY_REPEAT', + REORDER_PLAYER_TRACKLIST: 'REORDER_PLAYER_TRACKLIST', + + FETCH_PLAYER_SHUFFLE_PENDING: 'FETCH_PLAYER_SHUFFLE_PENDING', + FETCH_PLAYER_SHUFFLE_FAILED: 'FETCH_PLAYER_SHUFFLE_FAILED', + FETCH_PLAYER_SHUFFLE_FULFILLED: 'FETCH_PLAYER_SHUFFLE_FULFILLED', + + FETCH_PLAYER_TRACKS_PENDING: 'FETCH_PLAYER_TRACKS_PENDING', + FETCH_PLAYER_TRACKS_FAILED: 'FETCH_PLAYER_TRACKS_FAILED', + FETCH_PLAYER_TRACKS_FULFILLED: 'FETCH_PLAYER_TRACKS_FULFILLED', + + toggleQueue: () => ({ + type: playerActions.TOGGLE_QUEUE + }), + + togglePlayRepeat: () => ({ + type: playerActions.TOGGLE_PLAY_REPEAT + }), + + fetchPlayerShuffleRequestPending: (address) => ({ + type: playerActions.FETCH_PLAYER_SHUFFLE_PENDING, + payload: { + address + } + }), + + fetchPlayerShuffleRequestFailed: (address, error) => ({ + type: playerActions.FETCH_PLAYER_SHUFFLE_FAILED, + payload: { + address, + error + } + }), + + fetchPlayerShuffleRequestFulfilled: (address, data) => ({ + type: playerActions.FETCH_PLAYER_SHUFFLE_FULFILLED, + payload: { + address, + data + } + }), + + fetchPlayerTracksRequestPending: address => ({ + type: playerActions.FETCH_PLAYER_TRACKS_PENDING, + payload: { + address + } + }), + + fetchPlayerTracksRequestFailed: (address, error) => ({ + type: playerActions.FETCH_PLAYER_TRACKS_FAILED, + payload: { + address, + error + } + }), + + fetchPlayerTracksRequestFulfilled: (address, data) => ({ + type: playerActions.FETCH_PLAYER_TRACKS_FULFILLED, + payload: { + address, + data + } + }), + + queueTrack: ({ trackId, playNext }) => ({ + type: playerActions.QUEUE_TRACK, + payload: { + trackId, + playNext + } + }), + + unqueueTrack: ({ trackId, queueIndex }) => ({ + type: playerActions.UNQUEUE_TRACK, + payload: { + trackId, + queueIndex + } + }), + + reorderQueue: ({ oldIndex, newIndex }) => ({ + type: playerActions.REORDER_QUEUE, + payload: { + oldIndex, + newIndex + } + }), + + reorderPlayerTracklist: ({ oldIndex, newIndex }) => ({ + type: playerActions.REORDER_PLAYER_TRACKLIST, + payload: { + oldIndex, + newIndex + } + }), + + audioCancelled: () => ({ + type: playerActions.AUDIO_CANCELLED + }), + + clearQueue: () => ({ + type: playerActions.CLEAR_QUEUE + }), + + audioEnded: () => ({ + type: playerActions.AUDIO_ENDED + }), + + audioPaused: () => ({ + type: playerActions.AUDIO_PAUSED + }), + + audioPlaying: () => ({ + type: playerActions.AUDIO_PLAYING + }), + + audioTimeUpdated: times => ({ + type: playerActions.AUDIO_TIME_UPDATED, + payload: times + }), + + audioVolumeChanged: volume => ({ + type: playerActions.AUDIO_VOLUME_CHANGED, + payload: { + volume + } + }), + + playPrevious: (trackId, tracklistPreviousTrackId) => ({ + type: playerActions.PLAY_PREVIOUS, + payload: { + trackId, + tracklistPreviousTrackId + } + }), + + playNext: (trackId) => ({ + type: playerActions.PLAY_NEXT, + payload: { + trackId + } + }), + + // play from queue + playQueueTrack: (queueIndex) => ({ + type: playerActions.PLAY_QUEUE_TRACK, + payload: { + queueIndex + } + }), + + // play from player tracklist up next + playPlayerTracklistTrack: (index) => ({ + type: playerActions.PLAY_PLAYER_TRACKLIST_TRACK, + payload: { + index + } + }), + + // next, previous, audioEnd + playTrack: (trackId) => ({ + type: playerActions.PLAY_TRACK, + payload: { + trackId + } + }), + + // play button + playSelectedTrack: (trackId, tracklistAddress) => ({ + type: playerActions.PLAY_SELECTED_TRACK, + payload: { + trackId, + tracklistAddress + } + }), + + // play button + playTracklist: ({ + trackId, + tracklist, + startIndex, + tracklistAddress + }) => ({ + type: playerActions.PLAY_TRACKLIST, + payload: { + trackId, + tracklist, + startIndex, + tracklistAddress + } + }), + + stopShuffle: () => ({ + type: playerActions.STOP_SHUFFLE + }), + + shuffleSelectedTracklist: (tracklistAddress) => ({ + type: playerActions.SHUFFLE_SELECTED_TRACKLIST, + payload: { + tracklistAddress + } + }), + + shuffleTracklist: ({ tracklist, tracklistAddress }) => ({ + type: playerActions.SHUFFLE_TRACKLIST, + payload: { + tracklist, + tracklistAddress + } + }) +} + +export const playerTracksRequestActions = { + failed: playerActions.fetchPlayerTracksRequestFailed, + fulfilled: playerActions.fetchPlayerTracksRequestFulfilled, + pending: playerActions.fetchPlayerTracksRequestPending +} + +export const playerShuffleRequestActions = { + failed: playerActions.fetchPlayerShuffleRequestFailed, + fulfilled: playerActions.fetchPlayerShuffleRequestFulfilled, + pending: playerActions.fetchPlayerShuffleRequestPending +} diff --git a/app/core/player/index.js b/app/core/player/index.js new file mode 100644 index 00000000..f0a06ea6 --- /dev/null +++ b/app/core/player/index.js @@ -0,0 +1,23 @@ +export { + playerActions, + playerShuffleRequestActions, + playerTracksRequestActions +} from './actions' +export { playerReducer } from './player-reducer' +export { playerTimesReducer, PlayerTimesState } from './player-times-reducer' +export { playerSagas } from './sagas' + +export { + getPlayer, + getPlayerRepeat, + getPlayerTimes, + getPlayerTrack, + getPlayerTrackIds, + getPlayerTracklist, + getPlayerTracklistAddress, + getPlayerTracklistCursor, + getPlayerTracklistLog, + getPlayerQueue, + getTracksForQueue, + getTracksForPlayerTracklist +} from './selectors' diff --git a/app/core/player/player-reducer.js b/app/core/player/player-reducer.js new file mode 100644 index 00000000..34b3317b --- /dev/null +++ b/app/core/player/player-reducer.js @@ -0,0 +1,191 @@ +import { Record, List } from 'immutable' +import { PLAYER_INITIAL_VOLUME, ITEMS_PER_LOAD } from '@core/constants' +import { playerActions } from './actions' +import { mergeList } from '@core/utils' + +import { Tracklist } from '@core/tracklists' + +export const PlayerState = new Record({ + isPlaying: false, + isLoading: true, + repeat: 0, + isPlayingFromQueue: false, + isShuffling: false, + trackId: null, + history: new List(), + tracklist: new Tracklist(), + tracklistAddress: null, + tracklistCursorId: null, + tracklistStartIndex: null, + isQueueVisible: false, + queue: new List(), + volume: PLAYER_INITIAL_VOLUME +}) + +export function playerReducer (state = new PlayerState(), {payload, type}) { + switch (type) { + case playerActions.AUDIO_CANCELLED: + return state.set('isLoading', false) + + case playerActions.AUDIO_ENDED: + case playerActions.AUDIO_PAUSED: + return state.set('isPlaying', false) + + case playerActions.AUDIO_PLAYING: + return state.merge({ + isPlaying: true, + isLoading: false + }) + + case playerActions.TOGGLE_PLAY_REPEAT: + const repeat = state.repeat + 1 + return state.merge({ + repeat: repeat > 2 ? 0 : repeat + }) + + case playerActions.TOGGLE_QUEUE: + return state.merge({ + isQueueVisible: !state.isQueueVisible + }) + + case playerActions.FETCH_PLAYER_TRACKS_FULFILLED: + return state + .mergeIn(['tracklist', 'hasMore'], payload.data.length === ITEMS_PER_LOAD) + .mergeIn(['tracklist', 'trackIds'], mergeList(state.tracklist.trackIds, payload.data)) + + case playerActions.FETCH_PLAYER_SHUFFLE_FULFILLED: + return state.merge({ + trackId: payload.data[0].id + }).mergeIn(['tracklist', 'trackIds'], mergeList(state.tracklist.trackIds, payload.data.slice(1))) + + case playerActions.AUDIO_VOLUME_CHANGED: + return state.set('volume', payload.volume) + + case playerActions.STOP_SHUFFLE: + return state.merge({ + isShuffling: false + }) + + case playerActions.SHUFFLE_TRACKLIST: + return state.merge({ + isShuffling: true, + isPlayingFromQueue: false, + isLoading: true, + tracklist: payload.tracklist, + tracklistCursorId: null, + tracklistAddress: payload.tracklistAddress + }) + + case playerActions.CLEAR_QUEUE: { + return state.merge({ + queue: new List() + }) + } + + case playerActions.REORDER_QUEUE: { + const { oldIndex, newIndex } = payload + const trackId = state.queue.get(oldIndex) + return state.merge({ + queue: state.queue.delete(oldIndex).insert(newIndex, trackId) + }) + } + + case playerActions.REORDER_PLAYER_TRACKLIST: { + const { oldIndex, newIndex } = payload + const baseIndex = state.tracklist.trackIds.indexOf(state.tracklistCursorId) + 1 + const trackId = state.tracklist.trackIds.get(oldIndex + baseIndex) + return state.mergeIn(['tracklist', 'trackIds'], + state.tracklist.trackIds + .delete(baseIndex + oldIndex) + .insert(baseIndex + newIndex, trackId)) + } + + case playerActions.QUEUE_TRACK: + return state.merge({ + queue: payload.playNext + ? state.queue.unshift(payload.trackId) + : state.queue.push(payload.trackId) + }) + + case playerActions.UNQUEUE_TRACK: + return state.merge({ + queue: payload.queueIndex + ? state.queue.remove(payload.queueIndex) + : state.queue.filter(trackId => trackId !== payload.trackId) + }) + + case playerActions.PLAY_PREVIOUS: { + const { trackId, tracklistPreviousTrackId } = payload + return state.merge({ + history: state.history.shift(), + trackId, + tracklistCursorId: tracklistPreviousTrackId || state.tracklistCursorId, + isLoading: true + }) + } + + case playerActions.PLAY_NEXT: + case playerActions.PLAY_TRACK: { + const fromQueue = state.queue.first() === payload.trackId + const { isShuffling } = state + const cancelRepeat = state.repeat === 1 && playerActions.PLAY_NEXT === type + return state.merge({ + repeat: cancelRepeat ? 2 : state.repeat, + history: state.trackId ? state.history.unshift(state.trackId) : state.history, + trackId: payload.trackId, + tracklistCursorId: fromQueue || isShuffling ? state.tracklistCursorId : payload.trackId, + isPlayingFromQueue: fromQueue, + queue: fromQueue ? state.queue.shift() : state.queue, + isLoading: true + }).mergeIn(['tracklist', 'trackIds'], isShuffling + ? state.get('tracklist').get('trackIds').shift() + : new List() + ) + } + + case playerActions.PLAY_QUEUE_TRACK: { + const { queueIndex } = payload + const trackId = state.queue.get(queueIndex) + return state.merge({ + history: state.trackId ? state.history.unshift(state.trackId) : state.history, + trackId, + isPlayingFromQueue: true, + isLoading: true, + queue: state.queue.delete(queueIndex) + }) + } + + case playerActions.PLAY_PLAYER_TRACKLIST_TRACK: { + const { index } = payload + const base = state.isShuffling ? 0 : (state.tracklist.trackIds.indexOf(state.tracklistCursorId) + 1) + const trackId = state.tracklist.trackIds.get(base + index) + return state.merge({ + history: state.trackId ? state.history.unshift(state.trackId) : state.history, + trackId, + isPlayingFromQueue: false, + tracklistCursorId: state.isShuffing ? state.tracklistCursorId : trackId, + isLoading: true + }).mergeIn( + ['tracklist', 'trackIds'], + state.isShuffling ? state.tracklist.trackIds.delete(index) : state.tracklist.trackIds + ) + } + + case playerActions.PLAY_TRACKLIST: + return state.merge({ + history: state.trackId ? state.history.unshift(state.trackId) : state.history, + isLoading: true, + isShuffling: false, + isPlayingFromQueue: false, + repeat: 0, + tracklistCursorId: payload.trackId, + tracklistStartIndex: payload.startIndex, + trackId: payload.trackId, + tracklist: payload.tracklist, + tracklistAddress: payload.tracklistAddress + }) + + default: + return state + } +} diff --git a/app/core/player/player-times-reducer.js b/app/core/player/player-times-reducer.js new file mode 100644 index 00000000..16eec765 --- /dev/null +++ b/app/core/player/player-times-reducer.js @@ -0,0 +1,24 @@ +import { Record } from 'immutable' +import { playerActions } from './actions' + +export const PlayerTimesState = new Record({ + bufferedTime: 0, + currentTime: 0, + duration: 0, + percentBuffered: '0%', + percentCompleted: '0%' +}) + +export function playerTimesReducer (state = new PlayerTimesState(), {payload, type}) { + switch (type) { + case playerActions.AUDIO_ENDED: + case playerActions.PLAY_SELECTED_TRACK: + return new PlayerTimesState() + + case playerActions.AUDIO_TIME_UPDATED: + return state.merge(payload) + + default: + return state + } +} diff --git a/app/core/player/sagas.js b/app/core/player/sagas.js new file mode 100644 index 00000000..08c6fa8e --- /dev/null +++ b/app/core/player/sagas.js @@ -0,0 +1,237 @@ +import { List } from 'immutable' +import { eventChannel } from 'redux-saga' +import { call, fork, put, select, take, takeLatest, race, delay } from 'redux-saga/effects' +import { LOCATION_CHANGE } from 'react-router-redux' + +import { appActions, getApp } from '@core/app' +import { fetchPlayerTracks, fetchShuffleTracks, postListen } from '@core/api' +import { PLAYER_INITIAL_VOLUME, ITEMS_PER_LOAD } from '@core/constants' +import { getCurrentTracklist } from '@core/tracklists' +import { notificationActions } from '@core/notifications' +import { playerActions } from './actions' +import { audio, initAudio, setVolume } from '@core/audio' +import { + getPlayer, + getPlayerRepeat, + getPlayerTrack, + getPlayerTracklistCursor, + getPlayerTracklistAddress +} from './selectors' +import { playerStorage } from './storage' + +export function * playTrack () { + const { + tracklistCursorId, + tracklist, + isShuffling, + tracklistStartIndex + } = yield select(getPlayer) + + const { query, sort, order } = tracklist + const addresses = tracklist.addresses.toJS() + const tags = tracklist.tags.toJS() + const trackIds = tracklist.get('trackIds') + + if (!isShuffling) { + const cursorIndex = trackIds.indexOf(tracklistCursorId) + const tracksRemaining = trackIds.size - cursorIndex + + if (tracklist.hasMore && tracksRemaining < 3) { + const start = trackIds.size + tracklistStartIndex + const params = { + start, + addresses, + tags, + sort, + order, + query, + limit: ITEMS_PER_LOAD + } + yield call(fetchPlayerTracks, { params }) + } + } else if (trackIds.size < 3) { + // TODO: reload shuffle without replacement + const params = { shuffle: true, limit: 18, tags, query, addresses } + yield call(fetchShuffleTracks, { params }) + } +} + +export function * onAudioEnded () { + const repeat = yield select(getPlayerRepeat) + const cursor = yield select(getPlayerTracklistCursor) + + if (repeat === 1) { + yield put(playerActions.playTrack(cursor.selectedTrackId)) + } else if (cursor.nextTrackId) { + yield put(playerActions.playTrack(cursor.nextTrackId)) + } +} + +export function * shuffleTracklist ({ tracklistAddress }) { + let tracklist = yield select(getCurrentTracklist) + tracklist = tracklist.set('trackIds', new List()) + yield put(playerActions.shuffleTracklist({ + tracklist, + tracklistAddress + })) + const { query } = tracklist + const addresses = tracklist.addresses.toJS() + const tags = tracklist.tags.toJS() + const params = { shuffle: true, limit: 20, tags, query, addresses } + yield call(fetchShuffleTracks, { params }) + const player = yield select(getPlayer) + if (player.tracklist.trackIds.size) yield call(playAudio) +} + +export function * playTracklist ({ trackId, tracklistAddress }) { + const tracklist = yield select(getCurrentTracklist) + const startIndex = tracklist.trackIds.indexOf(trackId) + yield put(playerActions.playTracklist({ + trackId, + tracklist, + startIndex, + tracklistAddress + })) +} + +export function * playAudio () { + const track = yield select(getPlayerTrack) + yield call(audio.load, `${track.url}?trackId=${track.id}`) + yield call(audio.play) + + // record listens only after track loads + const [cancel] = yield race([ + delay(60000), + take(playerActions.AUDIO_PLAYING) + ]) + + if (cancel) { + yield call(audio.unload) + yield put(playerActions.audioCancelled()) + yield put(notificationActions.show({ + text: 'Track not currently available', + severity: 'warning' + })) + // TODO - add action to dispatch to notification (play next) + return + } + + // probably an anti-pattern but need to exclude stale saga effects + const nowplaying = yield select(getPlayerTrack) + if (track.id !== nowplaying.id) { + return + } + + const app = yield select(getApp) + const tracklistAddress = yield select(getPlayerTracklistAddress) + yield call(postListen, { + address: app.address, + data: { + address: tracklistAddress, + trackId: track.id, + cid: track.contentCID + } + }) +} + +export function * saveVolumeToStorage ({volume}) { + yield call(playerStorage.setVolume, volume) +} + +export function * setVolumeFromStorage () { + let volume = yield call(playerStorage.getVolume) + if (typeof volume !== 'number') volume = PLAYER_INITIAL_VOLUME + yield call(setVolume, volume) +} + +export function * subscribeToAudio () { + const channel = yield call(eventChannel, initAudio) + while (true) { + let action = yield take(channel) + yield put(action) + } +} + +export function * hideQueue () { + const { isQueueVisible } = yield select(getPlayer) + if (isQueueVisible) yield put(playerActions.toggleQueue()) +} + +//= ==================================== +// WATCHERS +// ------------------------------------- + +export function * watchAudioEnded () { + while (true) { + yield take(playerActions.AUDIO_ENDED) + yield fork(onAudioEnded) + } +} + +export function * watchAudioVolumeChanged () { + while (true) { + const { payload } = yield take(playerActions.AUDIO_VOLUME_CHANGED) + yield fork(saveVolumeToStorage, payload) + } +} + +export function * watchInitApp () { + while (true) { + yield take(appActions.INIT_APP) + yield fork(subscribeToAudio) + yield fork(setVolumeFromStorage) + } +} + +export function * watchPlay () { + yield takeLatest([ + playerActions.PLAY_PREVIOUS, + playerActions.PLAY_TRACKLIST, + playerActions.PLAY_QUEUE_TRACK, + playerActions.PLAY_PLAYER_TRACKLIST_TRACK + ], playAudio) +} + +export function * watchPlayTrack () { + while (true) { + yield take([playerActions.PLAY_TRACK, playerActions.PLAY_NEXT]) + yield fork(playAudio) + yield fork(playTrack) + } +} + +export function * watchPlaySelectedTrack () { + while (true) { + const { payload } = yield take(playerActions.PLAY_SELECTED_TRACK) + yield fork(playTracklist, payload) + } +} + +export function * watchShuffleTracklist () { + while (true) { + const { payload } = yield take(playerActions.SHUFFLE_SELECTED_TRACKLIST) + yield fork(shuffleTracklist, payload) + } +} + +export function * watchLocationChange () { + while (true) { + yield take(LOCATION_CHANGE) + yield fork(hideQueue) + } +} + +//= ==================================== +// ROOT +// ------------------------------------- + +export const playerSagas = [ + fork(watchPlay), + fork(watchAudioEnded), + fork(watchAudioVolumeChanged), + fork(watchInitApp), + fork(watchPlayTrack), + fork(watchPlaySelectedTrack), + fork(watchShuffleTracklist), + fork(watchLocationChange) +] diff --git a/app/core/player/selectors.js b/app/core/player/selectors.js new file mode 100644 index 00000000..feadbf74 --- /dev/null +++ b/app/core/player/selectors.js @@ -0,0 +1,123 @@ +import { createSelector } from 'reselect' + +import { getTracks, getTrackById, Track } from '@core/tracks' +import { getLogByAddress } from '@core/logs' + +export function getPlayer (state) { + return state.get('player') +} + +export function getPlayerRepeat (state) { + return getPlayer(state).repeat +} + +export function getPlayerTimes (state) { + return state.get('playerTimes') +} + +export function getPlayerTrackIds (state) { + const { trackId, tracklist, queue, history } = getPlayer(state) + const tracklistTrackIds = tracklist.get('trackIds') + return tracklistTrackIds.merge(queue, history).push(trackId) +} + +export function getPlayerQueue (state) { + return getPlayer(state).queue +} + +export function getPlayerTracklistAddress (state) { + return getPlayer(state).tracklistAddress +} + +export function getPlayerTrack (state) { + const { trackId } = getPlayer(state) + return getTrackById(state, trackId) || new Track() +} + +export function getPlayerTracklist (state) { + return getPlayer(state).tracklist +} + +export function getPlayerTracklistRemaining (state) { + const { tracklistCursorId, tracklist } = getPlayer(state) + const index = tracklist.get('trackIds').indexOf(tracklistCursorId) + return tracklist.get('trackIds').size - index +} + +export function getPlayerTracklistCursor (state) { + const { + queue, + repeat, + history, + tracklistCursorId, + trackId, + tracklist, + isShuffling + } = getPlayer(state) + + if (!trackId) { + return {} + } + + const trackIds = tracklist.get('trackIds') + const lastPlayedTrackId = history.first() + + if (isShuffling && !queue.size) { + return { + selectedTrackId: trackId, + nextTrackId: trackIds.first(), + previousTrackId: lastPlayedTrackId + } + } + + const index = trackIds.indexOf(tracklistCursorId) + let nextTrackId = null + let previousTrackId = null + + if (index !== -1) { + if (index < trackIds.size - 1) nextTrackId = trackIds.get(index + 1) + if (index > 0) previousTrackId = trackIds.get(index - 1) + } + + if (repeat === 2 && !nextTrackId) { + nextTrackId = trackIds.first() + } + + return { + selectedTrackId: trackId, + nextTrackId: queue.size ? queue.first() : nextTrackId, + previousTrackId: lastPlayedTrackId, + tracklistPreviousTrackId: previousTrackId + } +} + +export function getPlayerTracklistLog (state) { + const tracklistAddress = getPlayerTracklistAddress(state) + if (!tracklistAddress) { + return null + } + return getLogByAddress(state, tracklistAddress) +} + +//= ==================================== +// MEMOIZED SELECTORS +// ------------------------------------- + +export const getTracksForPlayerTracklist = createSelector( + getPlayer, + getPlayerTracklist, + (state) => getTracks(state), + (player, tracklist, tracks) => { + return player.isShuffling + ? tracklist.trackIds.map(id => tracks.get(id)) + : tracklist.trackIds.slice(tracklist.trackIds.indexOf(player.tracklistCursorId) + 1).map(id => tracks.get(id)) + } +) + +export const getTracksForQueue = createSelector( + getPlayerQueue, + (state) => getTracks(state), + (trackIds, tracks) => { + return trackIds.map(id => tracks.get(id)) + } +) diff --git a/app/core/player/storage.js b/app/core/player/storage.js new file mode 100644 index 00000000..c9b888ca --- /dev/null +++ b/app/core/player/storage.js @@ -0,0 +1,26 @@ +import { PLAYER_STORAGE_KEY } from '@core/constants' +import { localStorageAdapter } from '@core/utils' + +export const playerStorage = { + clear () { + localStorageAdapter.removeItem(PLAYER_STORAGE_KEY) + }, + + getPrefs () { + return localStorageAdapter.getItem(PLAYER_STORAGE_KEY) || {} + }, + + setPrefs (prefs) { + localStorageAdapter.setItem(PLAYER_STORAGE_KEY, prefs) + }, + + getVolume () { + return playerStorage.getPrefs().volume + }, + + setVolume (value) { + let prefs = playerStorage.getPrefs() + prefs.volume = value + playerStorage.setPrefs(prefs) + } +} diff --git a/app/core/reducers.js b/app/core/reducers.js new file mode 100644 index 00000000..5888c6f8 --- /dev/null +++ b/app/core/reducers.js @@ -0,0 +1,39 @@ +import { combineReducers } from 'redux-immutable' + +import { aboutReducer } from './about' +import { appReducer } from './app' +import { logsReducer } from './logs' +import { loglistsReducer } from './loglists' +import { contextMenuReducer } from './context-menu' +import { importerReducer } from './importer' +import { infoReducer } from './info' +import { helpReducer } from './help' +import { notificationReducer } from './notifications' +import { playerReducer, playerTimesReducer } from './player' +import { taglistsReducer } from './taglists' +import { tracklistsReducer } from './tracklists' +import { tracksReducer } from './tracks' +import { dialogReducer } from './dialogs' + +const rootReducer = asyncReducers => { + return combineReducers({ + about: aboutReducer, + app: appReducer, + logs: logsReducer, + loglists: loglistsReducer, + contextMenu: contextMenuReducer, + importer: importerReducer, + info: infoReducer, + help: helpReducer, + notification: notificationReducer, + player: playerReducer, + playerTimes: playerTimesReducer, + taglists: taglistsReducer, + tracklists: tracklistsReducer, + tracks: tracksReducer, + dialog: dialogReducer, + ...asyncReducers + }) +} + +export default rootReducer diff --git a/app/core/sagas.js b/app/core/sagas.js new file mode 100644 index 00000000..814f83d4 --- /dev/null +++ b/app/core/sagas.js @@ -0,0 +1,33 @@ +import { all } from 'redux-saga/effects' + +import { appSagas } from './app' +import { listensSagas } from './listens' +import { logsSagas } from './logs' +import { loglistSagas } from './loglists' +import { helpSagas } from './help' +import { importerSagas } from './importer' +import { infoSagas } from './info' +import { notificationSagas } from './notifications' +import { playerSagas } from './player' +import { aboutSagas } from './about' +import { taglistSagas } from './taglists' +import { trackSagas } from './tracks' +import { tracklistSagas } from './tracklists' + +export default function * rootSaga () { + yield all([ + ...appSagas, + ...listensSagas, + ...logsSagas, + ...loglistSagas, + ...importerSagas, + ...infoSagas, + ...helpSagas, + ...notificationSagas, + ...playerSagas, + ...aboutSagas, + ...taglistSagas, + ...trackSagas, + ...tracklistSagas + ]) +} diff --git a/src/core/store.js b/app/core/store.js similarity index 100% rename from src/core/store.js rename to app/core/store.js diff --git a/app/core/taglists/actions.js b/app/core/taglists/actions.js new file mode 100644 index 00000000..f5d0b36e --- /dev/null +++ b/app/core/taglists/actions.js @@ -0,0 +1,128 @@ +export const taglistActions = { + LOAD_TAGS: 'LOAD_TAGS', + + ADD_TAG: 'ADD_TAG', + REMOVE_TAG: 'REMOVE_TAG', + + FETCH_TAGS_FAILED: 'FETCH_TAGS_FAILED', + FETCH_TAGS_FULFILLED: 'FETCH_TAGS_FULFILLED', + FETCH_TAGS_PENDING: 'FETCH_TAGS_PENDING', + + POST_TAG_FAILED: 'POST_TAG_FAILED', + POST_TAG_FULFILLED: 'POST_TAG_FULFILLED', + POST_TAG_PENDING: 'POST_TAG_PENDING', + + DELETE_TAG_FAILED: 'DELETE_TAG_FAILED', + DELETE_TAG_FULFILLED: 'DELETE_TAG_FULFILLED', + DELETE_TAG_PENDING: 'DELETE_TAG_PENDING', + + fetchTagsFailed: (address, error) => ({ + type: taglistActions.FETCH_TAGS_FAILED, + payload: { + address, + error + } + }), + + fetchTagsFulfilled: (address, data) => ({ + type: taglistActions.FETCH_TAGS_FULFILLED, + payload: { + data, + address + } + }), + + fetchTagsPending: address => ({ + type: taglistActions.FETCH_TAGS_PENDING, + payload: { + address + } + }), + + loadTags: addresses => ({ + type: taglistActions.LOAD_TAGS, + payload: { + addresses + } + }), + + postTagFailed: (address, error) => ({ + type: taglistActions.POST_TAG_FAILED, + payload: { + address, + error + } + }), + + postTagFulfilled: (address, data) => ({ + type: taglistActions.POST_TAG_FULFILLED, + payload: { + address, + data + } + }), + + postTagPending: address => ({ + type: taglistActions.POST_TAG_PENDING, + payload: { + address + } + }), + + addTag: (address, data) => ({ + type: taglistActions.ADD_TAG, + payload: { + address, + data + } + }), + + deleteTagFailed: (address, error) => ({ + type: taglistActions.DELETE_TAG_FAILED, + payload: { + address, + error + } + }), + + deleteTagFulfilled: (address, data) => ({ + type: taglistActions.DELETE_TAG_FULFILLED, + payload: { + address, + data + } + }), + + deleteTagPending: address => ({ + type: taglistActions.DELETE_TAG_PENDING, + payload: { + address + } + }), + + removeTag: (address, data) => ({ + type: taglistActions.REMOVE_TAG, + payload: { + address, + data + } + }) +} + +export const taglistRequestActions = { + failed: taglistActions.fetchTagsFailed, + fulfilled: taglistActions.fetchTagsFulfilled, + pending: taglistActions.fetchTagsPending +} + +export const taglistPostActions = { + failed: taglistActions.postTagFailed, + fulfilled: taglistActions.postTagFulfilled, + pending: taglistActions.postTagPending +} + +export const taglistDeleteActions = { + failed: taglistActions.deleteTagFailed, + fulfilled: taglistActions.deleteTagFulfilled, + pending: taglistActions.deleteTagPending +} diff --git a/app/core/taglists/index.js b/app/core/taglists/index.js new file mode 100644 index 00000000..d4c1d42e --- /dev/null +++ b/app/core/taglists/index.js @@ -0,0 +1,13 @@ +export { taglistsReducer } from './taglists-reducer' +export { + getCurrentTaglist, + getTagsForUser, + getTagsForCurrentTaglist +} from './selectors' +export { taglistSagas } from './sagas' +export { + taglistActions, + taglistRequestActions, + taglistPostActions, + taglistDeleteActions +} from './actions' diff --git a/app/core/taglists/sagas.js b/app/core/taglists/sagas.js new file mode 100644 index 00000000..42ebc031 --- /dev/null +++ b/app/core/taglists/sagas.js @@ -0,0 +1,126 @@ +import { List } from 'immutable' +import { call, fork, takeLatest, takeLeading, select, put } from 'redux-saga/effects' + +import history from '@core/history' +import { fetchTags, postTag, deleteTag } from '@core/api' +import { appActions } from '@core/app' +import { notificationActions } from '@core/notifications' +import { tracklistActions, getCurrentTracklist } from '@core/tracklists' +import { taglistActions } from './actions' + +export function * loadTags () { + const tracklist = yield select(getCurrentTracklist) + const params = { addresses: tracklist.addresses.toJS() } + yield call(fetchTags, { params }) +} + +export function * addTag ({ payload }) { + const { address, data } = payload + if (!data.tag) return + yield call(postTag, { address, data }) +} + +export function * removeTag ({ payload }) { + const { address, data } = payload + yield call(deleteTag, { address, data }) +} + +// make sure selected tags all exist - otherwise clear/reload +export function * checkSelectedTags ({ payload }) { + let tracklist = yield select(getCurrentTracklist) + const existingTags = payload.data.map(t => t.tag) + + if (history.location.pathname !== tracklist.path) return + + let selectedTags = tracklist.tags.toJS() + let shouldClear = false + for (const tag of selectedTags) { + if (!existingTags.includes(tag)) { + shouldClear = true + break + } + } + + if (shouldClear) { + tracklist = tracklist.set('tags', new List()) + const action = tracklistActions.loadTracks({ ...tracklist.toJS() }) + yield put(action) + } +} + +export function * postTagPending () { + yield put(notificationActions.show({ + text: 'Adding tag', + dismiss: 1250 + })) +} + +export function * deleteTagFailed () { + yield put(notificationActions.show({ + text: 'Failed to remove tag', + severity: 'error', + dismiss: 2000 + })) +} + +export function * postTagFailed () { + yield put(notificationActions.show({ + text: 'Failed to add tag', + severity: 'error', + dismiss: 2000 + })) +} + +export function * watchLoadTags () { + yield takeLatest(taglistActions.LOAD_TAGS, loadTags) +} + +export function * watchAddTag () { + yield takeLatest(taglistActions.ADD_TAG, addTag) +} + +export function * watchRemoveTag () { + yield takeLatest(taglistActions.REMOVE_TAG, removeTag) +} + +export function * watchInitApp () { + yield takeLeading(appActions.INIT_APP, loadTags) +} + +export function * watchPostTagFulfilled () { + yield takeLatest(taglistActions.POST_TAG_FULFILLED, loadTags) +} + +export function * watchDeleteTagFulfilled () { + yield takeLatest(taglistActions.DELETE_TAG_FULFILLED, loadTags) +} + +export function * watchFetchTagsFulfilled () { + yield takeLatest(taglistActions.FETCH_TAGS_FULFILLED, checkSelectedTags) +} + +export function * watchDeleteTagFailed () { + yield takeLatest(taglistActions.DELETE_TAG_FAILED, deleteTagFailed) +} + +export function * watchPostTagFailed () { + yield takeLatest(taglistActions.POST_TAG_FAILED, postTagFailed) +} + +export function * watchPostTagPending () { + yield takeLatest(taglistActions.POST_TAG_PENDING, postTagPending) +} + +export const taglistSagas = [ + fork(watchLoadTags), + fork(watchInitApp), + fork(watchAddTag), + fork(watchRemoveTag), + fork(watchPostTagFulfilled), + fork(watchDeleteTagFulfilled), + fork(watchFetchTagsFulfilled), + fork(watchPostTagPending), + + fork(watchDeleteTagFailed), + fork(watchPostTagFailed) +] diff --git a/app/core/taglists/selectors.js b/app/core/taglists/selectors.js new file mode 100644 index 00000000..3cf4dfce --- /dev/null +++ b/app/core/taglists/selectors.js @@ -0,0 +1,32 @@ +import { createSelector } from 'reselect' + +import { CURRENT_TAGLIST_ADDRESS } from '@core/constants' +import { getApp } from '@core/app' + +export function getTaglists (state) { + return state.get('taglists') +} + +export function getTaglistByAddress (state, address) { + return getTaglists(state).get(address) +} + +export function getCurrentTaglist (state) { + const taglists = getTaglists(state) + return taglists.get(CURRENT_TAGLIST_ADDRESS) +} + +export function getUserTaglist (state) { + const app = getApp(state) + return getTaglistByAddress(state, app.address) +} + +export const getTagsForUser = createSelector( + getUserTaglist, + taglist => taglist ? taglist.tags.map(t => t.tag) : [] +) + +export const getTagsForCurrentTaglist = createSelector( + getCurrentTaglist, + taglist => taglist.tags +) diff --git a/app/core/taglists/taglist-reducer.js b/app/core/taglists/taglist-reducer.js new file mode 100644 index 00000000..c9ddead2 --- /dev/null +++ b/app/core/taglists/taglist-reducer.js @@ -0,0 +1,22 @@ +import { List } from 'immutable' + +import { taglistActions } from './actions' +import { Taglist } from './taglist' + +export function taglistReducer (state = new Taglist(), { payload, type }) { + switch (type) { + case taglistActions.FETCH_TAGS_FULFILLED: + return state.withMutations(taglist => { + taglist.merge({ + isPending: false, + tags: new List(payload.data) + }) + }) + + case taglistActions.FETCH_TAGS_PENDING: + return state.set('isPending', true) + + default: + return state + } +} diff --git a/app/core/taglists/taglist.js b/app/core/taglists/taglist.js new file mode 100644 index 00000000..b4a668c1 --- /dev/null +++ b/app/core/taglists/taglist.js @@ -0,0 +1,6 @@ +import { List, Record } from 'immutable' + +export const Taglist = new Record({ + isPending: false, + tags: new List() +}) diff --git a/app/core/taglists/taglists-reducer.js b/app/core/taglists/taglists-reducer.js new file mode 100644 index 00000000..10ddc903 --- /dev/null +++ b/app/core/taglists/taglists-reducer.js @@ -0,0 +1,27 @@ +import { Map } from 'immutable' + +import { taglistActions } from './actions' +import { taglistReducer } from './taglist-reducer' +import { Taglist } from './taglist' +import { CURRENT_TAGLIST_ADDRESS } from '@core/constants' + +export const initialState = new Map({ + [CURRENT_TAGLIST_ADDRESS]: new Taglist() +}) + +// TODO update taglist on new entries (remote or local) + +export function taglistsReducer (state = initialState, action) { + switch (action.type) { + case taglistActions.FETCH_TAGS_FULFILLED: + case taglistActions.FETCH_TAGS_PENDING: + case taglistActions.LOAD_TAGS: + return state.set( + CURRENT_TAGLIST_ADDRESS, + taglistReducer(state.get(CURRENT_TAGLIST_ADDRESS), action) + ) + + default: + return state + } +} diff --git a/app/core/tracklists/actions.js b/app/core/tracklists/actions.js new file mode 100644 index 00000000..59b93a24 --- /dev/null +++ b/app/core/tracklists/actions.js @@ -0,0 +1,168 @@ +export const tracklistActions = { + LOAD_TRACKS: 'LOAD_TRACKS', + LOAD_NEXT_TRACKS: 'LOAD_NEXT_TRACKS', + + ADD_TRACK: 'ADD_TRACK', + REMOVE_TRACK: 'REMOVE_TRACK', + + TOGGLE_TAG: 'TOGGLE_TAG', + SEARCH_TRACKS: 'SEARCH_TRACKS', + CLEAR_SEARCH: 'CLEAR_SEARCH', + REORDER_TRACKLIST: 'REORDER_TRACKLIST', + + FETCH_TRACKS_FAILED: 'FETCH_TRACKS_FAILED', + FETCH_TRACKS_FULFILLED: 'FETCH_TRACKS_FULFILLED', + FETCH_TRACKS_PENDING: 'FETCH_TRACKS_PENDING', + + POST_TRACK_FAILED: 'POST_TRACK_FAILED', + POST_TRACK_FULFILLED: 'POST_TRACK_FULFILLED', + POST_TRACK_PENDING: 'POST_TRACK_PENDING', + + DELETE_TRACK_FAILED: 'DELETE_TRACK_FAILED', + DELETE_TRACK_FULFILLED: 'DELETE_TRACK_FULFILLED', + DELETE_TRACK_PENDING: 'DELETE_TRACK_PENDING', + + fetchTracksFailed: (address, error) => ({ + type: tracklistActions.FETCH_TRACKS_FAILED, + payload: { + address, + error + } + }), + + fetchTracksFulfilled: (address, data) => ({ + type: tracklistActions.FETCH_TRACKS_FULFILLED, + payload: { + data, + address + } + }), + + fetchTracksPending: address => ({ + type: tracklistActions.FETCH_TRACKS_PENDING, + payload: { + address + } + }), + + loadTracks: ({ path, addresses, tags, query, order, sort }) => ({ + type: tracklistActions.LOAD_TRACKS, + payload: { + path, + addresses, + tags, + query, + order, + sort + } + }), + + toggleTag: (tag) => ({ + type: tracklistActions.TOGGLE_TAG, + payload: { + tag + } + }), + + loadNextTracks: () => ({ + type: tracklistActions.LOAD_NEXT_TRACKS + }), + + postTrackFailed: (address, error) => ({ + type: tracklistActions.POST_TRACK_FAILED, + payload: { + address, + error + } + }), + + postTrackFulfilled: (address, data) => ({ + type: tracklistActions.POST_TRACK_FULFILLED, + payload: { + address, + data + } + }), + + postTrackPending: address => ({ + type: tracklistActions.POST_TRACK_PENDING, + payload: { + address + } + }), + + addTrack: (address, data) => ({ + type: tracklistActions.ADD_TRACK, + payload: { + address, + data + } + }), + + removeTrack: (address, data) => ({ + type: tracklistActions.REMOVE_TRACK, + payload: { + address, + data + } + }), + + deleteTrackFailed: (address, error) => ({ + type: tracklistActions.DELETE_TRACK_FAILED, + payload: { + address, + error + } + }), + + deleteTrackPending: address => ({ + type: tracklistActions.DELETE_TRACK_PENDING, + payload: { + address + } + }), + + deleteTrackFulfilled: (address, data) => ({ + type: tracklistActions.DELETE_TRACK_FULFILLED, + payload: { + address, + data + } + }), + + clearSearch: () => ({ + type: tracklistActions.CLEAR_SEARCH + }), + + searchTracks: (query) => ({ + type: tracklistActions.SEARCH_TRACKS, + payload: { + query + } + }), + + reorderTracklist: (sort) => ({ + type: tracklistActions.REORDER_TRACKLIST, + payload: { + sort + } + }) +} + +export const tracklistRequestActions = { + failed: tracklistActions.fetchTracksFailed, + fulfilled: tracklistActions.fetchTracksFulfilled, + pending: tracklistActions.fetchTracksPending +} + +export const tracklistPostActions = { + failed: tracklistActions.postTrackFailed, + fulfilled: tracklistActions.postTrackFulfilled, + pending: tracklistActions.postTrackPending +} + +export const tracklistDeleteActions = { + failed: tracklistActions.deleteTrackFailed, + fulfilled: tracklistActions.deleteTrackFulfilled, + pending: tracklistActions.deleteTrackPending +} diff --git a/src/core/tracklists/index.js b/app/core/tracklists/index.js similarity index 69% rename from src/core/tracklists/index.js rename to app/core/tracklists/index.js index 4a54e8c2..2ad2c96d 100644 --- a/src/core/tracklists/index.js +++ b/app/core/tracklists/index.js @@ -1,13 +1,17 @@ export { tracklistActions, tracklistRequestActions, - tracklistPostActions + tracklistPostActions, + tracklistDeleteActions } from './actions' export { tracklistSagas } from './sagas' export { getCurrentTracklist, + getCurrentTracklistLog, getTracksForCurrentTracklist } from './selectors' export { tracklistsReducer } from './tracklists-reducer' + +export { Tracklist } from './tracklist' diff --git a/app/core/tracklists/sagas.js b/app/core/tracklists/sagas.js new file mode 100644 index 00000000..30105ab9 --- /dev/null +++ b/app/core/tracklists/sagas.js @@ -0,0 +1,134 @@ +import { call, fork, put, select, takeLatest, takeEvery } from 'redux-saga/effects' + +import { fetchTracks, postTrack, deleteTrack } from '@core/api' +import { ITEMS_PER_LOAD } from '@core/constants' +import { notificationActions } from '@core/notifications' +import { tracklistActions } from './actions' +import { getCurrentTracklist } from './selectors' + +export function * addTrack ({ payload }) { + const { address, data } = payload + yield fork(postTrack, { address, data }) + yield put(notificationActions.show({ + text: 'Adding', + dismiss: 2000 + })) +} + +export function * loadNextTracks () { + const tracklist = yield select(getCurrentTracklist) + const { query, sort, order } = tracklist + const addresses = tracklist.addresses.toJS() + const tags = tracklist.tags.toJS() + const start = tracklist.trackIds.size + const params = { start, limit: ITEMS_PER_LOAD, tags, query, addresses, sort, order } + yield call(fetchTracks, { address: tracklist.address, params }) +} + +export function * loadTracks () { + const tracklist = yield select(getCurrentTracklist) + const { query, sort, order } = tracklist + const addresses = tracklist.addresses.toJS() + const tags = tracklist.tags.toJS() + const params = { start: 0, limit: ITEMS_PER_LOAD, tags, query, addresses, sort, order } + yield call(fetchTracks, { params }) +} + +export function * reorderTracklist ({ payload }) { + const { sort } = payload + let tracklist = yield select(getCurrentTracklist) + if (!tracklist.sort || sort !== tracklist.sort) { + tracklist = tracklist.merge({ + sort, + order: 'asc' + }) + } else if (tracklist.order === 'asc') { + tracklist = tracklist.merge({ order: 'desc' }) + } else { + tracklist = tracklist.merge({ order: null, sort: null }) + } + yield put(tracklistActions.loadTracks({ ...tracklist.toJS() })) +} + +export function * removeTrack ({ payload }) { + const { address, data } = payload + yield call(deleteTrack, { address, data }) +} + +export function * postTrackFailed () { + yield put(notificationActions.show({ + text: 'Failed to add track', + severity: 'error', + dismiss: 2000 + })) +} + +export function * deleteTrackFailed () { + yield put(notificationActions.show({ + text: 'Failed to remove track', + severity: 'error', + dismiss: 2000 + })) +} + +//= ==================================== +// WATCHERS +// ------------------------------------- + +export function * watchAddTrack () { + yield takeEvery(tracklistActions.ADD_TRACK, addTrack) +} + +export function * watchLoadNextTracks () { + yield takeLatest(tracklistActions.LOAD_NEXT_TRACKS, loadNextTracks) +} + +export function * watchLoadTracks () { + yield takeLatest(tracklistActions.LOAD_TRACKS, loadTracks) +} + +export function * watchRemoveTrack () { + yield takeEvery(tracklistActions.REMOVE_TRACK, removeTrack) +} + +export function * watchSearchTracks () { + yield takeLatest(tracklistActions.SEARCH_TRACKS, loadTracks) +} + +export function * watchClearSearch () { + yield takeLatest(tracklistActions.CLEAR_SEARCH, loadTracks) +} + +export function * watchToggleTag () { + yield takeLatest(tracklistActions.TOGGLE_TAG, loadTracks) +} + +export function * watchDeleteTrackFailed () { + yield takeLatest(tracklistActions.DELETE_TRACK_FAILED, deleteTrackFailed) +} + +export function * watchPostTrackFailed () { + yield takeLatest(tracklistActions.POST_TRACK_FAILED, postTrackFailed) +} + +export function * watchReorderTracklist () { + yield takeLatest(tracklistActions.REORDER_TRACKLIST, reorderTracklist) +} + +//= ==================================== +// ROOT +// ------------------------------------- + +export const tracklistSagas = [ + fork(watchAddTrack), + fork(watchLoadNextTracks), + fork(watchLoadTracks), + fork(watchRemoveTrack), + fork(watchSearchTracks), + fork(watchClearSearch), + fork(watchToggleTag), + fork(watchReorderTracklist), + + fork(watchPostTrackFailed), + fork(watchDeleteTrackFailed) +] diff --git a/src/core/tracklists/selectors.js b/app/core/tracklists/selectors.js similarity index 51% rename from src/core/tracklists/selectors.js rename to app/core/tracklists/selectors.js index 961c0d37..3584a180 100644 --- a/src/core/tracklists/selectors.js +++ b/app/core/tracklists/selectors.js @@ -1,18 +1,25 @@ import { createSelector } from 'reselect' +import { CURRENT_TRACKLIST_ADDRESS } from '@core/constants' +import { getLogByAddress } from '@core/logs' import { getTracks } from '@core/tracks' export function getTracklists (state) { return state.get('tracklists') } -export function getTracklistById (state, logId) { - return getTracklists(state).get(logId) +export function getCurrentTracklist (state) { + return getTracklists(state).get(CURRENT_TRACKLIST_ADDRESS) } -export function getCurrentTracklist (state) { - let tracklists = getTracklists(state) - return tracklists.get(tracklists.get('currentTracklistId')) +export function getCurrentTracklistLog (state) { + const tracklist = getCurrentTracklist(state) + if (!tracklist) { + return null + } + + const addresses = tracklist.get('addresses') + return getLogByAddress(state, addresses.first()) } //= ==================================== @@ -26,7 +33,7 @@ export const getCurrentTrackIds = createSelector( export const getTracksForCurrentTracklist = createSelector( getCurrentTrackIds, - getTracks, + (state) => getTracks(state), // FIX for https://stackoverflow.com/questions/35240716/webpack-import-returns-undefined-depending-on-the-order-of-imports (trackIds, tracks) => { return trackIds.map(id => tracks.get(id)) } diff --git a/app/core/tracklists/tracklist-reducer.js b/app/core/tracklists/tracklist-reducer.js new file mode 100644 index 00000000..281a1270 --- /dev/null +++ b/app/core/tracklists/tracklist-reducer.js @@ -0,0 +1,103 @@ +import { List } from 'immutable' + +import { ITEMS_PER_LOAD } from '@core/constants' +import { tracklistActions } from './actions' +import { Tracklist } from './tracklist' +import { logActions } from '@core/logs' +import { mergeList } from '@core/utils' +import { listensActions } from '@core/listens' +import history from '@core/history' + +export function tracklistReducer (state = new Tracklist(), {payload, type}) { + switch (type) { + case tracklistActions.CLEAR_SEARCH: + return state.withMutations(tracklist => { + tracklist.merge({ + query: null, + trackIds: new List() + }) + }) + + case tracklistActions.SEARCH_TRACKS: + return state.withMutations(tracklist => { + const { query } = payload + tracklist.merge({ query, trackIds: new List() }) + }) + + case listensActions.FETCH_LISTENS_FULFILLED: + case tracklistActions.FETCH_TRACKS_FULFILLED: + return state.withMutations(tracklist => { + tracklist.merge({ + isPending: false, + hasMore: payload.data.length === ITEMS_PER_LOAD, + trackIds: mergeList(tracklist.trackIds, payload.data) + }) + }) + + case listensActions.POST_LISTEN_FULFILLED: { + if (state.path !== '/listens') { + return state + } + + return state.updateIn(['trackIds'], t => t.unshift(payload.data.trackId)) + } + + case tracklistActions.TOGGLE_TAG: + const { tag } = payload + return state.withMutations(tracklist => { + tracklist.merge({ + trackIds: new List(), + tags: state.tags.includes(tag) + ? state.tags.splice(state.tags.indexOf(tag), 1) + : state.tags.push(tag) + }) + }) + + case logActions.LOG_INDEX_UPDATED: { + if (!payload.data || !payload.data.length) { + return state + } + + if (history.location.pathname.substring(0, 7) !== '/tracks') { + return state + } + + const trackEntries = payload.data.filter(entry => entry.payload.value.type === 'track') + if (!trackEntries.length) { + return state + } + + const isOutdated = state.addresses.includes(payload.address) + return state.merge({ isOutdated: isOutdated || state.isOutdated }) + } + + case listensActions.FETCH_LISTENS_FAILED: + case tracklistActions.FETCH_TRACKS_FAILED: + return state.set('isPending', false) + + case listensActions.FETCH_LISTENS_PENDING: + case tracklistActions.FETCH_TRACKS_PENDING: + return state.set('isPending', true) + + case listensActions.LOAD_LISTENS: + return state.merge({ + path: '/listens', + trackIds: new List() + }) + + case tracklistActions.LOAD_TRACKS: + return state.merge({ + isOutdated: false, + path: payload.path, + addresses: payload.addresses, + sort: payload.sort, + order: payload.order, + query: payload.query, + tags: payload.tags ? new List(payload.tags) : new List(), + trackIds: new List() + }) + + default: + return state + } +} diff --git a/app/core/tracklists/tracklist.js b/app/core/tracklists/tracklist.js new file mode 100644 index 00000000..5d851f95 --- /dev/null +++ b/app/core/tracklists/tracklist.js @@ -0,0 +1,14 @@ +import { List, Record } from 'immutable' + +export const Tracklist = new Record({ + path: null, + addresses: new List(), + sort: null, + order: 'desc', + isOutdated: false, // when new tracks have been added (sync, chrome extension, etc) + isPending: false, // when fetching / loading + hasMore: true, + query: null, + tags: new List(), + trackIds: new List() +}) diff --git a/app/core/tracklists/tracklists-reducer.js b/app/core/tracklists/tracklists-reducer.js new file mode 100644 index 00000000..daf728e0 --- /dev/null +++ b/app/core/tracklists/tracklists-reducer.js @@ -0,0 +1,43 @@ +import { Map } from 'immutable' + +import { + CURRENT_TRACKLIST_ADDRESS +} from '@core/constants' +import { tracklistActions } from './actions' +import { tracklistReducer } from './tracklist-reducer' +import { Tracklist } from './tracklist' +import { trackActions } from '@core/tracks' +import { logActions } from '@core/logs' +import { listensActions } from '@core/listens' +import { importerActions } from '@core/importer' + +const initialState = new Map({ + [CURRENT_TRACKLIST_ADDRESS]: new Tracklist() +}) + +export function tracklistsReducer (state = initialState, action) { + switch (action.type) { + case tracklistActions.CLEAR_SEARCH: + case tracklistActions.FETCH_TRACKS_FULFILLED: + case tracklistActions.FETCH_TRACKS_PENDING: + case tracklistActions.SEARCH_TRACKS: + case tracklistActions.TOGGLE_TAG: + case tracklistActions.LOAD_TRACKS: + case listensActions.FETCH_LISTENS_PENDING: + case listensActions.FETCH_LISTENS_FULFILLED: + case listensActions.FETCH_LISTENS_FAILED: + case listensActions.LOAD_LISTENS: + case listensActions.POST_LISTEN_FULFILLED: + case tracklistActions.POST_TRACK_FULFILLED: + case importerActions.IMPORTER_PROCESSED_FILE: + case trackActions.TRACK_ADDED: + case logActions.LOG_INDEX_UPDATED: + return state.set( + CURRENT_TRACKLIST_ADDRESS, + tracklistReducer(state.get(CURRENT_TRACKLIST_ADDRESS), action) + ) + + default: + return state + } +} diff --git a/app/core/tracks/actions.js b/app/core/tracks/actions.js new file mode 100644 index 00000000..9e35ddf4 --- /dev/null +++ b/app/core/tracks/actions.js @@ -0,0 +1,11 @@ +export const trackActions = { + TRACK_ADDED: 'TRACK_ADDED', + CLEAR: 'CLEAR', + + clearTracks: (trackIds) => ({ + type: trackActions.CLEAR, + payload: { + trackIds + } + }) +} diff --git a/src/core/tracks/index.js b/app/core/tracks/index.js similarity index 64% rename from src/core/tracks/index.js rename to app/core/tracks/index.js index 3ccc4bc0..0ae754d3 100644 --- a/src/core/tracks/index.js +++ b/app/core/tracks/index.js @@ -1,3 +1,5 @@ export { tracksReducer } from './reducer' export { getTrackById, getTracks } from './selectors' export { Track, createTrack } from './track' +export { trackActions } from './actions' +export { trackSagas } from './sagas' diff --git a/app/core/tracks/reducer.js b/app/core/tracks/reducer.js new file mode 100644 index 00000000..60d39cf1 --- /dev/null +++ b/app/core/tracks/reducer.js @@ -0,0 +1,93 @@ +import { Map, List } from 'immutable' + +import { tracklistActions } from '@core/tracklists' +import { taglistActions } from '@core/taglists' +import { createTrack } from './track' +import { trackActions } from './actions' +import { playerActions } from '@core/player' +import { listensActions } from '@core/listens' +import { importerActions } from '@core/importer' + +export function tracksReducer (state = new Map(), {payload, type}) { + switch (type) { + case trackActions.CLEAR: + return state.filter((value, key) => payload.trackIds ? payload.trackIds.contains(key) : false) + + case listensActions.FETCH_LISTENS_FULFILLED: + case playerActions.FETCH_PLAYER_TRACKS_FULFILLED: + case playerActions.FETCH_PLAYER_SHUFFLE_FULFILLED: + case tracklistActions.FETCH_TRACKS_FULFILLED: + return state.withMutations(tracks => { + payload.data.forEach(trackData => { + tracks.set(trackData.id, createTrack(trackData)) + }) + }) + + case trackActions.TRACK_ADDED: + return state.withMutations(tracks => { + const track = tracks.get(payload.data.id) + if (track) { + return + } + tracks.set(payload.data.id, createTrack(payload.data)) + }) + + case importerActions.IMPORTER_PROCESSED_FILE: + return state.withMutations(tracks => { + tracks.set(payload.track.id, createTrack(payload.track)) + }) + + case tracklistActions.ADD_TRACK: { + if (!payload.data.cid) { + return state + } + + const track = state.find(t => t.contentCID === payload.data.cid) + return state.setIn([track.id, 'isUpdating'], true) + } + + case tracklistActions.REMOVE_TRACK: + return state.withMutations(tracks => { + tracks.setIn([payload.data.trackId, 'isUpdating'], true) + }) + + case taglistActions.POST_TAG_FULFILLED: + case taglistActions.DELETE_TAG_FULFILLED: { + const { id, tags } = payload.data + return state.withMutations(tracks => { + tracks.map(track => { + tracks.setIn([id, 'tags'], new List(tags)) + if (type === taglistActions.POST_TAG_FULFILLED) { + tracks.setIn([id, 'haveTrack'], true) + } + }) + }) + } + + case tracklistActions.DELETE_TRACK_FAILED: + case tracklistActions.POST_TRACK_FAILED: { + // TODO + return state + } + + case tracklistActions.DELETE_TRACK_FULFILLED: + return state.mergeIn([payload.data.trackId], { isUpdating: false, haveTrack: false }) + + case tracklistActions.POST_TRACK_FULFILLED: + return state.mergeIn([payload.data.id], { isUpdating: false, haveTrack: true }) + + case listensActions.POST_LISTEN_FULFILLED: { + const { trackId, timestamps } = payload.data + if (!state.has(trackId)) { + return state + } + + return state.withMutations(tracks => { + tracks.setIn([trackId, 'listens'], new List(timestamps)) + }) + } + + default: + return state + } +} diff --git a/app/core/tracks/sagas.js b/app/core/tracks/sagas.js new file mode 100644 index 00000000..3fc8aea5 --- /dev/null +++ b/app/core/tracks/sagas.js @@ -0,0 +1,18 @@ +import { fork, takeLatest, select, put } from 'redux-saga/effects' + +import { trackActions } from './actions' +import { tracklistActions } from '@core/tracklists' +import { getPlayerTrackIds } from '@core/player' + +export function * clearTracks () { + const trackIds = yield select(getPlayerTrackIds) + yield put(trackActions.clearTracks(trackIds)) +} + +export function * watchLoadTracks () { + yield takeLatest(tracklistActions.LOAD_TRACKS, clearTracks) +} + +export const trackSagas = [ + fork(watchLoadTracks) +] diff --git a/src/core/tracks/selectors.js b/app/core/tracks/selectors.js similarity index 100% rename from src/core/tracks/selectors.js rename to app/core/tracks/selectors.js diff --git a/app/core/tracks/track.js b/app/core/tracks/track.js new file mode 100644 index 00000000..f3fb3164 --- /dev/null +++ b/app/core/tracks/track.js @@ -0,0 +1,98 @@ +import { Record, List } from 'immutable' + +import { BASE_URL } from '@core/constants' + +export const Track = new Record({ + duration: null, + id: null, + thumbnail: null, + title: null, + name: null, + artist: null, + remixer: null, + format: null, + bitrate: null, + url: null, + tags: new List(), + isLocal: false, + haveTrack: false, + isUpdating: false, + webpage_url: null, + contentCID: null, + listens: new List() +}) + +const getFromResolver = (resolver, attribute) => { + if (!resolver.length) { + return null + } + + const item = resolver.find(r => r[attribute]) + return item && item[attribute] +} + +const getArtwork = (content) => { + if (!content.artwork.length) return getFromResolver(content.resolver, 'thumbnail') + + const artwork = content.artwork[0] + return `${BASE_URL}/file/${artwork}` +} + +const getTitle = (content) => { + if (content.tags.title && content.tags.artist) { + return `${content.tags.artist} - ${content.tags.title}` + } else if (content.tags.title || content.tags.artist) { + return content.tags.artist || content.tags.title + } + + return getFromResolver(content.resolver, 'fulltitle') +} + +const getUrl = (content) => { + if (content.hash) { + return `${BASE_URL}/file/${content.hash}` + } + + return getFromResolver(content.resolver, 'url') +} + +const getInfo = (content) => { + let artist = content.tags.artist + let name = content.tags.title + let remixer = null + + if (!name) { + name = getFromResolver(content.resolver, 'fulltitle') + } + + return { artist, name, remixer } +} + +export function createTrack (data) { + if (!data.content) { + return + } + + const artwork = getArtwork(data.content) + const title = getTitle(data.content) + const { name, artist, remixer } = getInfo(data.content) + const url = getUrl(data.content) + + return new Track({ + duration: data.content.audio.duration, + id: data.id, + thumbnail: artwork, + format: data.content.audio.container, + bitrate: data.content.audio.bitrate, + title, + artist, + name, + remixer, + url, + isLocal: !!data.isLocal, + contentCID: data.contentCID, + listens: new List(data.listens), + haveTrack: !!data.haveTrack, + tags: new List(data.tags) + }) +} diff --git a/src/views/components/CopyText/CopyText.js b/app/core/utils/clip-to-clipboard.js similarity index 61% rename from src/views/components/CopyText/CopyText.js rename to app/core/utils/clip-to-clipboard.js index 826c5396..bea7dfd5 100644 --- a/src/views/components/CopyText/CopyText.js +++ b/app/core/utils/clip-to-clipboard.js @@ -1,8 +1,4 @@ -import React from 'react' - -import './CopyText.styl' - -const copyToClipboard = str => { +export const copyToClipboard = str => { // https://gist.github.com/Chalarangelo/4ff1e8c0ec03d9294628efbae49216db#file-copytoclipboard-js const el = document.createElement('textarea') // Create a