├── .dockerignore ├── .gitignore ├── .travis.yml ├── Dockerfile ├── LICENSE ├── README.md ├── codecov.yaml ├── config-overrides.js ├── docs ├── overview.png └── shuffle.md ├── nginx.conf ├── package.json ├── public ├── album_placeholder.jpg ├── currently_placeholder.png ├── favicon.ico ├── images │ └── icons │ │ ├── icon-128x128.png │ │ ├── icon-144x144.png │ │ ├── icon-152x152.png │ │ ├── icon-192x192.png │ │ ├── icon-384x384.png │ │ ├── icon-512x512.png │ │ ├── icon-72x72.png │ │ └── icon-96x96.png ├── index.html └── manifest.json ├── src ├── App.js ├── App.less ├── App.test.js ├── Main.js ├── Main.test.js ├── api │ └── subsonicApi.js ├── components │ ├── Album │ │ ├── Album.js │ │ ├── Album.less │ │ ├── Album.test.js │ │ └── index.js │ ├── AlbumView │ │ ├── AlbumView.js │ │ ├── AlbumView.test.js │ │ └── index.js │ ├── AlbumsList │ │ ├── AlbumsList.js │ │ ├── AlbumsList.test.js │ │ ├── NavigationButtons.test.js │ │ └── index.js │ ├── AlbumsListFilter │ │ ├── AlbumsListFilter.js │ │ ├── AlbumsListFilter.test.js │ │ └── index.js │ ├── AlertsManager │ │ ├── AlertsManager.js │ │ ├── AlertsManager.test.js │ │ └── index.js │ ├── Artist │ │ ├── Artist.js │ │ ├── Artist.test.js │ │ └── index.js │ ├── ArtistAllSongs │ │ ├── ArtistAllSongs.js │ │ ├── ArtistAllSongs.test.js │ │ └── index.js │ ├── ArtistByAlbums │ │ ├── ArtistByAlbums.js │ │ ├── ArtistByAlbums.less │ │ ├── ArtistByAlbums.test.js │ │ └── index.js │ ├── ArtistListElement │ │ ├── ArtistListElement.js │ │ ├── ArtistListElement.less │ │ ├── ArtistListElement.test.js │ │ └── index.js │ ├── ArtistListHeader │ │ ├── ArtistListHeader.js │ │ ├── ArtistListHeader.test.js │ │ └── index.js │ ├── ArtistListLoader │ │ ├── ArtistListLoader.js │ │ ├── ArtistListLoader.test.js │ │ └── index.js │ ├── ArtistsList │ │ ├── ArtistsList.js │ │ ├── ArtistsList.test.js │ │ └── index.js │ ├── AuthenticatedComponent │ │ ├── AuthenticatedComponent.js │ │ ├── AuthenticatedComponent.test.js │ │ └── index.js │ ├── CreatePlaylistModal │ │ ├── CreatePlaylistModal.js │ │ ├── CreatePlaylistModal.test.js │ │ └── index.js │ ├── DeletePlaylistModal │ │ ├── DeletePlaylistModal.js │ │ ├── DeletePlaylistModal.test.js │ │ └── index.js │ ├── EditPlaylistModal │ │ ├── EditPlaylistModal.js │ │ ├── EditPlaylistModal.test.js │ │ └── index.js │ ├── FavouritesView │ │ ├── FavouritesView.js │ │ ├── FavouritesView.test.js │ │ └── index.js │ ├── GenreSongs │ │ ├── GenreSongs.js │ │ ├── GenreSongs.test.js │ │ └── index.js │ ├── GenresPicker │ │ ├── GenresPicker.js │ │ ├── GenresPicker.test.js │ │ └── index.js │ ├── GenresView │ │ ├── GenresView.js │ │ ├── GenresView.test.js │ │ └── index.js │ ├── InfiniteLineLoader │ │ ├── InfiniteLineLoader.js │ │ ├── InfiniteLineLoader.less │ │ ├── InfiniteLineLoader.test.js │ │ └── index.js │ ├── LoginView │ │ ├── LoginView.js │ │ ├── LoginView.test.js │ │ └── index.js │ ├── MusicPlayer │ │ ├── MusicPlayer.js │ │ ├── MusicPlayer.less │ │ ├── MusicPlayer.test.js │ │ └── index.js │ ├── Navbar │ │ ├── Navbar.js │ │ ├── Navbar.test.js │ │ └── index.js │ ├── Playlist │ │ ├── Playlist.js │ │ ├── Playlist.less │ │ ├── Playlist.test.js │ │ └── index.js │ ├── PlaylistSelectorDropdown │ │ ├── PlaylistSelectorDropdown.js │ │ ├── PlaylistSelectorDropdown.test.js │ │ └── index.js │ ├── QueueView │ │ ├── QueueView.js │ │ ├── QueueView.less │ │ ├── QueueView.test.js │ │ └── index.js │ ├── RecentlyAddedView │ │ ├── RecentlyAddedView.js │ │ ├── RecentlyAddedView.less │ │ ├── RecentlyAddedView.test.js │ │ └── index.js │ ├── ResponsiveTitle │ │ ├── ResponsiveTitle.js │ │ ├── ResponsiveTitle.test.js │ │ └── index.js │ ├── ScrobbleSetting │ │ ├── ScrobbleSetting.js │ │ ├── ScrobbleSetting.test.js │ │ └── index.js │ ├── SearchAlbumResult │ │ ├── SearchAlbumResult.js │ │ ├── SearchAlbumResult.less │ │ ├── SearchAlbumResult.test.js │ │ └── index.js │ ├── SearchBar │ │ ├── SearchBar.js │ │ ├── SearchBar.test.js │ │ └── index.js │ ├── SearchSongResult │ │ ├── SearchSongResult.js │ │ ├── SearchSongResult.test.js │ │ └── index.js │ ├── SearchView │ │ ├── SearchView.js │ │ ├── SearchView.test.js │ │ └── index.js │ ├── SettingsView │ │ ├── SettingsView.js │ │ ├── SettingsView.test.js │ │ └── index.js │ ├── Sidebar │ │ ├── Sidebar.js │ │ ├── Sidebar.less │ │ ├── Sidebar.test.js │ │ └── index.js │ ├── SidebarSettings │ │ ├── SidebarSettings.js │ │ ├── SidebarSettings.test.js │ │ └── index.js │ ├── SongsTable │ │ ├── SongsTable.js │ │ ├── SongsTable.less │ │ ├── SongsTable.test.js │ │ └── index.js │ ├── SongsTableEnhanced │ │ ├── SongsTableEnhanced.js │ │ ├── SongsTableEnhanced.test.js │ │ └── index.js │ └── ThemePicker │ │ ├── ThemePicker.js │ │ ├── ThemePicker.less │ │ ├── ThemePicker.test.js │ │ └── index.js ├── index.js ├── redux │ ├── actions │ │ ├── _tests_ │ │ │ ├── albumActions.test.js │ │ │ ├── apiStatusActions.test.js │ │ │ ├── artistsActions.test.js │ │ │ ├── authActions.test.js │ │ │ ├── favouritesActions.test.js │ │ │ ├── genresActions.test.js │ │ │ ├── playlistsActions.test.js │ │ │ ├── searchActions.test.js │ │ │ └── songsActions.test.js │ │ ├── actionTypes.js │ │ ├── albumActions.js │ │ ├── alertsActions.js │ │ ├── apiStatusActions.js │ │ ├── artistsActions.js │ │ ├── authActions.js │ │ ├── favouritesActions.js │ │ ├── genresActions.js │ │ ├── playlistsActions.js │ │ ├── searchActions.js │ │ └── songsActions.js │ ├── configureStore.dev.js │ ├── configureStore.js │ ├── configureStore.prod.js │ ├── reducers │ │ ├── _tests_ │ │ │ ├── albumsReducer.test.js │ │ │ ├── alertsReducer.test.js │ │ │ ├── artistsReducer.test.js │ │ │ ├── asyncTasksReducer.test.js │ │ │ ├── authReducer.test.js │ │ │ ├── favouritesReducer.test.js │ │ │ ├── musicPlayerReducer.test.js │ │ │ ├── playlistsReducer.test.js │ │ │ ├── searchReducer.test.js │ │ │ └── songsReducer.test.js │ │ ├── albumsReducer.js │ │ ├── alertsReducer.js │ │ ├── artistsReducer.js │ │ ├── asyncTasksReducer.js │ │ ├── authReducer.js │ │ ├── favouritesReducer.js │ │ ├── index.js │ │ ├── initialState.js │ │ ├── musicPlayerReducer.js │ │ ├── playlistsReducer.js │ │ ├── searchReducer.js │ │ └── songsReducer.js │ └── selectors │ │ ├── _tests_ │ │ ├── albumSelectors.test.js │ │ ├── artistSelectors.test.js │ │ ├── musicPlayerSelector.test.js │ │ ├── searchSelectors.test.js │ │ └── songSelectors.test.js │ │ ├── albumSelectors.js │ │ ├── artistSelectors.js │ │ ├── musicPlayerSelector.js │ │ ├── searchSelectors.js │ │ └── songSelectors.js ├── serviceWorker.js ├── setupTests.js └── utils │ ├── formatting.js │ ├── redux.js │ ├── settings.js │ ├── theming.js │ └── utils.js ├── themes.config.js ├── themes.content.js └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | .gitignore 4 | build 5 | README.md 6 | docs -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # Sublime stuff 26 | *.sublime-project 27 | *.sublime-workspace 28 | 29 | # Compiled themes 30 | /src/less/* 31 | /src/themes.config.js -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - node 4 | - lts/* 5 | install: 6 | - yarn global add codecov 7 | - yarn 8 | script: 9 | - yarn test --coverage --watchAll=false && codecov -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | # Stage 1 3 | FROM node:lts as react-build 4 | WORKDIR /app 5 | COPY . ./ 6 | RUN yarn 7 | RUN yarn build 8 | 9 | # Stage 2 - the production environment 10 | FROM nginx:alpine 11 | ENV NODE_ENV="production" 12 | COPY --from=react-build /app/build /usr/share/nginx/html 13 | RUN rm /etc/nginx/conf.d/default.conf 14 | COPY nginx.conf /etc/nginx/conf.d 15 | EXPOSE 80 16 | CMD ["nginx", "-g", "daemon off;"] -------------------------------------------------------------------------------- /codecov.yaml: -------------------------------------------------------------------------------- 1 | codecov: 2 | require_ci_to_pass: yes 3 | 4 | coverage: 5 | precision: 2 6 | round: down 7 | range: "70...100" 8 | 9 | parsers: 10 | gcov: 11 | branch_detection: 12 | conditional: yes 13 | loop: yes 14 | method: no 15 | macro: no 16 | 17 | comment: 18 | layout: "reach,diff,flags,tree" 19 | behavior: default 20 | require_changes: no 21 | -------------------------------------------------------------------------------- /config-overrides.js: -------------------------------------------------------------------------------- 1 | /* config-overrides.js */ 2 | const path = require('path'); 3 | const themes = require('./themes.config'); 4 | const themeContent = require('./themes.content'); 5 | 6 | const merge = require('webpack-merge'); 7 | const multipleThemesCompile = require('webpack-multiple-themes-compile/src'); 8 | const multipleEntry = require('react-app-rewire-multiple-entry') 9 | 10 | 11 | // Validate themes before compiling 12 | function validateThemes(themes) { 13 | // Check that themes start with "light" or "dark" 14 | const themeNames = Object.keys(themes) 15 | for (var i = 0; i < themeNames.length; i++) { 16 | const name = themeNames[i] 17 | if( !(name.startsWith("light") || name.startsWith("dark")) ) { 18 | throw new Error("Invalid themes. Please check README for details.") 19 | } 20 | } 21 | console.log("Valid themes found.") 22 | } 23 | validateThemes(themes) 24 | 25 | 26 | module.exports = { 27 | webpack: function(config, env) { 28 | // This is to re-format CRA's entries to make them compatible with multipleThemesCompile() 29 | const appEntries = multipleEntry([{entry : 'src/index.js'}]) 30 | appEntries.addMultiEntry(config) 31 | // Add theming 32 | let multiTheme = multipleThemesCompile({ 33 | themesConfig: themes, 34 | lessContent: (themeName, config) => themeContent(themeName), 35 | styleLoaders: [ 36 | { loader: 'css-loader' }, 37 | { 38 | loader: 'less-loader', 39 | options: { 40 | lessOptions: { 41 | javascriptEnabled: true 42 | } 43 | } 44 | } 45 | ], 46 | cwd: path.resolve('./') 47 | }) 48 | 49 | /* Store the rule to load less/css files to put it where CRA stores these kind of rules 50 | * (otherwise, duplicate rules with existing and the compiler will complain) */ 51 | var lessRule = multiTheme["module"]["rules"][0] 52 | delete multiTheme["module"]["rules"] 53 | 54 | // Change the location of css files to import them by name without knowing the id 55 | multiTheme["plugins"][0]["options"]["chunkFilename"] = "css/[name].css" 56 | 57 | // Merge default CRA config with themes' config. Now, we just need to fix the lessRule. 58 | var newConfig = merge(config, multiTheme) 59 | 60 | // Add themes' rule to the beginning of existing 'oneOf' to make it the first to be applied 61 | newConfig["module"]["rules"][2]["oneOf"].unshift(lessRule) 62 | 63 | return newConfig 64 | } 65 | }; -------------------------------------------------------------------------------- /docs/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/docs/overview.png -------------------------------------------------------------------------------- /docs/shuffle.md: -------------------------------------------------------------------------------- 1 | # Shuffle Behaviour 2 | 3 | ## Desired beaviour 4 | 5 | If a song is already playing, when: 6 | * turning shuffle on: the song currently playing goes to the front of the queue and the rest of the songs are shuffled (even if they were already played) 7 | * turning shuffle off: the song currently playing goes to the front of the queue and the rest is filled only with the songs that came after in the original order 8 | 9 | When playing a new list, if: 10 | * the shuffle is on: everything is shuffled and a random song is played. 11 | * the shuffle is off: the list starts reproducing from top to bottom. 12 | 13 | ## Implementation 14 | 15 | The status is saved in the store as: 16 | ``` 17 | { 18 | songsById: {}, 19 | original : [id1, id2, id3, id4, id5], 20 | currentSongId: id1 /* this is the one that matters when updating the state of the views */ 21 | currentSongIndex : 3, 22 | queue : [id3, id4, id1, id2, id5], 23 | isShuffleOn : true, 24 | } 25 | ``` 26 | 27 | If shuffle is switched on: 28 | ``` 29 | { 30 | songsById: /* doesn't change */, 31 | original: /* doesn't change */, 32 | currentSongId: /* doesn't change */, 33 | currentSongIndex: /* is now */ 0, 34 | queue: /* is now: */ [currentSongId, the original queue (except currentSongId) shuffled ], 35 | isShuffleOn: true 36 | } 37 | ``` 38 | 39 | If shuffle is switched off: 40 | ``` 41 | { 42 | songsById: /* doesn't change */, 43 | original: /* doesn't change */, 44 | currentSongId: /* doesn't change */, 45 | currentSongIndex: /* is now */ 0, 46 | queue: /* is now: */ [currentSongId, the original queue starting from currentSongId ], 47 | isShuffleOn: false 48 | } 49 | ``` 50 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | #server_name localhost; 4 | #charset koi8-r; 5 | #access_log /var/log/nginx/host.access.log main; 6 | 7 | location / { 8 | root /usr/share/nginx/html; 9 | index index.html index.htm; 10 | try_files $uri /index.html; 11 | } 12 | 13 | #error_page 404 /404.html; 14 | 15 | # redirect server error pages to the static page /50x.html 16 | # 17 | error_page 500 502 503 504 /50x.html; 18 | location = /50x.html { 19 | root /usr/share/nginx/html; 20 | } 21 | 22 | # proxy the PHP scripts to Apache listening on 127.0.0.1:80 23 | # 24 | #location ~ \.php$ { 25 | # proxy_pass http://127.0.0.1; 26 | #} 27 | 28 | # pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000 29 | # 30 | #location ~ \.php$ { 31 | # root html; 32 | # fastcgi_pass 127.0.0.1:9000; 33 | # fastcgi_index index.php; 34 | # fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name; 35 | # include fastcgi_params; 36 | #} 37 | 38 | # deny access to .htaccess files, if Apache's document root 39 | # concurs with nginx's one 40 | # 41 | #location ~ /\.ht { 42 | # deny all; 43 | #} 44 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "subsonic-player", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@reach/router": "^1.2.1", 7 | "@welldone-software/why-did-you-render": "^3.2.3", 8 | "customize-cra": "^0.4.1", 9 | "enzyme": "^3.10.0", 10 | "enzyme-adapter-react-16": "^1.14.0", 11 | "fetch-mock": "^7.3.9", 12 | "gensync": "^1.0.0-beta.1", 13 | "howler": "^2.1.2", 14 | "less": "^3.9.0", 15 | "less-loader": "6.1.2", 16 | "rc-slider": "^9.5.4", 17 | "react": "^16.8.6", 18 | "react-app-rewire-multiple-entry": "^2.2.0", 19 | "react-app-rewired": "^2.1.3", 20 | "react-dom": "^16.8.6", 21 | "react-infinite-scroller": "^1.2.4", 22 | "react-redux": "^7.1.0", 23 | "react-scripts": "3.0.1", 24 | "react-virtualized-auto-sizer": "^1.0.2", 25 | "redux": "^4.0.4", 26 | "redux-immutable-state-invariant": "^2.1.0", 27 | "redux-mock-store": "^1.5.3", 28 | "redux-thunk": "^2.3.0", 29 | "reselect": "^4.0.0", 30 | "rsuite": "4.7.2", 31 | "webpack-merge": "^4.2.2", 32 | "webpack-multiple-themes-compile": "^2.0.0" 33 | }, 34 | "scripts": { 35 | "start": "(rm -r src/less || true ) && (cp themes.config.js src/themes.config.js) && react-app-rewired start", 36 | "build": "(rm -r src/less || true ) && (cp themes.config.js src/themes.config.js) && react-app-rewired build", 37 | "test": "(rm -r src/less || true ) && (cp themes.config.js src/themes.config.js) && react-app-rewired test --env=jsdom", 38 | "eject": "react-app-rewired eject" 39 | }, 40 | "eslintConfig": { 41 | "extends": "react-app" 42 | }, 43 | "browserslist": { 44 | "production": [ 45 | ">0.2%", 46 | "not dead", 47 | "not op_mini all" 48 | ], 49 | "development": [ 50 | "last 1 chrome version", 51 | "last 1 firefox version", 52 | "last 1 safari version" 53 | ] 54 | }, 55 | "jest": { 56 | "collectCoverageFrom": [ 57 | "src/**/*.{js,jsx}", 58 | "!src/components/**/index.js", 59 | "!src/**/*.dev.js", 60 | "!/node_modules/", 61 | "!src/index.js", 62 | "!src/serviceWorker.js" 63 | ] 64 | }, 65 | "devDependencies": { 66 | "jest-react-hooks-shallow": "^1.4.1" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /public/album_placeholder.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/album_placeholder.jpg -------------------------------------------------------------------------------- /public/currently_placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/currently_placeholder.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/favicon.ico -------------------------------------------------------------------------------- /public/images/icons/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/images/icons/icon-128x128.png -------------------------------------------------------------------------------- /public/images/icons/icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/images/icons/icon-144x144.png -------------------------------------------------------------------------------- /public/images/icons/icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/images/icons/icon-152x152.png -------------------------------------------------------------------------------- /public/images/icons/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/images/icons/icon-192x192.png -------------------------------------------------------------------------------- /public/images/icons/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/images/icons/icon-384x384.png -------------------------------------------------------------------------------- /public/images/icons/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/images/icons/icon-512x512.png -------------------------------------------------------------------------------- /public/images/icons/icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/images/icons/icon-72x72.png -------------------------------------------------------------------------------- /public/images/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/peguerosdc/subplayer/ade2450e4a38744cb0354417e7ddcfc3f45dd9c7/public/images/icons/icon-96x96.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 21 | SubPlayer 22 | 23 | 24 | 25 |
26 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "SubPlayer", 3 | "short_name": "SubPlayer", 4 | "theme_color": "rgb(29,45,60)", 5 | "background_color": "#fa2755", 6 | "display": "standalone", 7 | "orientation": "portrait", 8 | "start_url": "/", 9 | "icons": [ 10 | { 11 | "src": "images/icons/icon-72x72.png", 12 | "sizes": "72x72", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "images/icons/icon-96x96.png", 17 | "sizes": "96x96", 18 | "type": "image/png" 19 | }, 20 | { 21 | "src": "images/icons/icon-128x128.png", 22 | "sizes": "128x128", 23 | "type": "image/png" 24 | }, 25 | { 26 | "src": "images/icons/icon-144x144.png", 27 | "sizes": "144x144", 28 | "type": "image/png" 29 | }, 30 | { 31 | "src": "images/icons/icon-152x152.png", 32 | "sizes": "152x152", 33 | "type": "image/png" 34 | }, 35 | { 36 | "src": "images/icons/icon-192x192.png", 37 | "sizes": "192x192", 38 | "type": "image/png" 39 | }, 40 | { 41 | "src": "images/icons/icon-384x384.png", 42 | "sizes": "384x384", 43 | "type": "image/png" 44 | }, 45 | { 46 | "src": "images/icons/icon-512x512.png", 47 | "sizes": "512x512", 48 | "type": "image/png" 49 | } 50 | ], 51 | "splash_pages": null 52 | } -------------------------------------------------------------------------------- /src/App.less: -------------------------------------------------------------------------------- 1 | @scrollbar-width: 8px; 2 | 3 | /*Scrollbar*/ 4 | &::-webkit-scrollbar { 5 | border-radius: (@scrollbar-width / 2); 6 | width: @scrollbar-width; 7 | } 8 | 9 | /*Handle*/ 10 | &::-webkit-scrollbar-thumb { 11 | border-radius: (@scrollbar-width / 2); 12 | background-color: fade(#8d8d8d, 30); 13 | &:hover { 14 | background-color: fade(#8d8d8d, 90); 15 | } 16 | } 17 | 18 | /*Track*/ 19 | &::-webkit-scrollbar-track { 20 | border-radius: (@scrollbar-width / 2); 21 | background: transparent; 22 | } -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import {App} from "./App" 4 | 5 | describe("", () => { 6 | 7 | it("renders without crashing", () => { 8 | shallow( null} logout={() => null} /> ) 9 | }) 10 | 11 | it("starts loading the list of playlists to display when mounted", () => { 12 | const loadPlaylists = jest.fn() 13 | const wrapper = shallow( null} /> ) 14 | expect(loadPlaylists).toHaveBeenCalledTimes(1) 15 | }) 16 | 17 | it("shows a loader when async tasks are pending", () => { 18 | const wrapper = shallow( null} logout={() => null} asyncTasksInProgress={5} /> ) 19 | expect(wrapper.find("#loader").prop("isLoading")).toBeTruthy() 20 | }) 21 | 22 | it("should show the playlist creation modal when selected from the navbar", () => { 23 | const wrapper = shallow( null} logout={() => null} /> ) 24 | wrapper.find("#mobileNavbar").simulate("createPlaylistTrigger") 25 | expect(wrapper.find("#createPlaylistModal").prop("showModal")).toBeTruthy() 26 | }) 27 | 28 | it("should show the playlist creation modal when selected from the side bar", () => { 29 | const wrapper = shallow( null} logout={() => null} /> ) 30 | wrapper.find("#sidebar").simulate("createPlaylistTrigger") 31 | expect(wrapper.find("#createPlaylistModal").prop("showModal")).toBeTruthy() 32 | }) 33 | 34 | it("should hide the playlist creation modal when commanded", () => { 35 | const wrapper = shallow( null} logout={() => null} /> ) 36 | // First, show the modal 37 | wrapper.find("#sidebar").simulate("createPlaylistTrigger") 38 | // Now, close it 39 | wrapper.find("#createPlaylistModal").simulate("closePlaylistModal") 40 | expect(wrapper.find("#createPlaylistModal").prop("showModal")).toBeFalsy() 41 | }) 42 | 43 | 44 | }) -------------------------------------------------------------------------------- /src/Main.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import App from './App' 3 | import { Router } from "@reach/router" 4 | // Redux imports 5 | import { Provider } from 'react-redux' 6 | import configureStore from "./redux/configureStore" 7 | // My components 8 | import AuthenticatedComponent from './components/AuthenticatedComponent' 9 | import Login from './components/LoginView' 10 | 11 | // Default components 12 | const NotFound = () =>

404! Sorry, nothing here

13 | // Init app 14 | const store = configureStore() 15 | 16 | export default (props) => ( 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | ) -------------------------------------------------------------------------------- /src/Main.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import Main from './Main' 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div') 7 | ReactDOM.render(
, div) 8 | ReactDOM.unmountComponentAtNode(div) 9 | }) 10 | -------------------------------------------------------------------------------- /src/components/Album/Album.less: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/questions/9940016/how-to-space-text-like-using-tab-on-website 2 | .album-panel p strong { 3 | display: inline-block; 4 | *display: inline; 5 | zoom: 1; 6 | width: 50px; 7 | margin-left: 10px; 8 | } 9 | 10 | .album-panel .artist-link { 11 | cursor: pointer; 12 | font-weight: bold; 13 | } 14 | 15 | .album-panel .artist-link:hover { 16 | text-decoration: underline; 17 | } -------------------------------------------------------------------------------- /src/components/Album/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { addSongsToPlaylist } from "../../redux/actions/playlistsActions" 4 | import { setStarOnAlbums } from "../../redux/actions/albumActions" 5 | import { makeGetSongsOfAlbum } from '../../redux/selectors/songSelectors' 6 | // UI 7 | import Album from './Album' 8 | 9 | const mapStateToProps = (state, ownProps) => { 10 | const getSongsOfAlbum = makeGetSongsOfAlbum() 11 | return { 12 | "album" : state.albums.byId[ownProps.albumId], 13 | "songs" : getSongsOfAlbum(state, ownProps), 14 | } 15 | } 16 | 17 | const mapDispatchToProps = { addSongsToPlaylist, starAlbums: setStarOnAlbums } 18 | 19 | export default connect(mapStateToProps, mapDispatchToProps)(Album) -------------------------------------------------------------------------------- /src/components/AlbumView/AlbumView.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from 'prop-types' 3 | // UI 4 | import Album from '../Album' 5 | 6 | export default class AlbumView extends React.PureComponent { 7 | 8 | componentDidMount() { 9 | this.props.loadAlbum(this.props.albumId) 10 | } 11 | 12 | render() { 13 | const albumId = this.props.albumId 14 | return ( 15 |
16 | 17 |
18 | ) 19 | } 20 | } 21 | 22 | AlbumView.propTypes = { 23 | loadAlbum : PropTypes.func.isRequired, 24 | albumId : PropTypes.string.isRequired, 25 | } 26 | 27 | AlbumView.defaultProps = { 28 | loadAlbum : () => {}, 29 | albumId : null, 30 | } -------------------------------------------------------------------------------- /src/components/AlbumView/AlbumView.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import AlbumView from "./AlbumView" 4 | 5 | 6 | function setup() { 7 | 8 | const props = { 9 | loadAlbum: jest.fn(), 10 | albumId : "album_id" 11 | } 12 | 13 | return {props, albumView : } 14 | } 15 | 16 | describe("", () => { 17 | 18 | it("renders without crashing", () => { 19 | const { albumView } = setup() 20 | shallow( albumView ) 21 | }) 22 | 23 | it("should start loading album when mounting", () => { 24 | const {props, albumView} = setup() 25 | const enzymeWrapper = shallow( albumView ) 26 | expect( props.loadAlbum ).toHaveBeenCalled() 27 | }) 28 | 29 | }) -------------------------------------------------------------------------------- /src/components/AlbumView/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { loadAlbum } from "../../redux/actions/albumActions" 4 | // UI 5 | import AlbumView from './AlbumView' 6 | 7 | const mapDispatchToProps = { loadAlbum } 8 | 9 | export default connect(null, mapDispatchToProps)(AlbumView) -------------------------------------------------------------------------------- /src/components/AlbumsList/AlbumsList.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme' 3 | import {AlbumsList} from "./AlbumsList" 4 | 5 | describe("", () => { 6 | 7 | it("renders without crashing and loads albums", () => { 8 | const loadAlbums = jest.fn() 9 | shallow( ) 10 | expect(loadAlbums).toHaveBeenCalledTimes(1) 11 | }) 12 | 13 | it("renders a list of albums without crashing", () => { 14 | const albums = [{id:1}, {id:2}] 15 | shallow( null} albums={[]} /> ) 16 | }) 17 | 18 | it("re-loads albums when a filter changes", () => { 19 | const loadAlbums = jest.fn() 20 | const filter = "new filter" 21 | const wrapper = shallow( ) 22 | wrapper.find("#albumsFilter").simulate("filterChanged", {filter}) 23 | expect(loadAlbums).toHaveBeenLastCalledWith(filter, {}, 0) 24 | }) 25 | 26 | it("loads albums when the next page is clicked (both for MD and mobile screens)", () => { 27 | const loadAlbums = jest.fn() 28 | const filter = "new filter" 29 | const wrapper = shallow( ) 30 | wrapper.find("#pageNavigation").simulate("next") 31 | wrapper.find("#pageNavigationMobile").simulate("next") 32 | // 3 times: 1 on first render, and other 2 when clicking both "next"s 33 | expect(loadAlbums).toHaveBeenCalledTimes(3) 34 | }) 35 | 36 | it("loads albums when the previous page is clicked (both for MD and mobile screens)", () => { 37 | const loadAlbums = jest.fn() 38 | const filter = "new filter" 39 | const wrapper = shallow( ) 40 | wrapper.find("#pageNavigation").simulate("previous") 41 | wrapper.find("#pageNavigationMobile").simulate("previous") 42 | // 3 times: 1 on first render, and other 2 when clicking both "next"s 43 | expect(loadAlbums).toHaveBeenCalledTimes(3) 44 | }) 45 | 46 | }) -------------------------------------------------------------------------------- /src/components/AlbumsList/NavigationButtons.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import {NavigationButtons} from "./AlbumsList" 4 | 5 | describe("", () => { 6 | 7 | it("renders without crashing", () => { 8 | shallow( ) 9 | }) 10 | 11 | }) -------------------------------------------------------------------------------- /src/components/AlbumsList/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { loadAlbums } from "../../redux/actions/albumActions" 4 | import { albumsSelector } from "../../redux/selectors/albumSelectors" 5 | // UI 6 | import {AlbumsList} from "./AlbumsList" 7 | 8 | const mapStateToProps = (state, ownProps) => { 9 | return { 10 | "albums" : albumsSelector(state) 11 | } 12 | } 13 | 14 | const mapDispatchToProps = { loadAlbums } 15 | 16 | export default connect(mapStateToProps, mapDispatchToProps)(AlbumsList) -------------------------------------------------------------------------------- /src/components/AlbumsListFilter/AlbumsListFilter.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from "react" 2 | // UI 3 | import {RadioGroup, Radio, InputNumber, Button, Alert } from 'rsuite' 4 | import GenresPicker from "../GenresPicker" 5 | 6 | export default function AlbumsListFilter(props) { 7 | // Filter details 8 | const [yearFrom, setYearFrom] = useState(null) 9 | const [yearTo, setYearTo] = useState(null) 10 | 11 | // Filters selection 12 | const [filter, setFilter] = useState("newest") 13 | 14 | // Change the settings when the radio option has changed 15 | function onRadioChanged(value) { 16 | setFilter(value) 17 | // Show the years input 18 | if( value !== "byYear" && value !== "byGenre") { 19 | props.onFilterChanged({ filter: value }) 20 | } 21 | } 22 | 23 | // On genre changed 24 | function onGenreChanged(genre) { 25 | if( genre !== null) { 26 | props.onFilterChanged({ filter: filter, genre:genre.value }) 27 | } 28 | } 29 | 30 | // WHen the search button is pressed 31 | function onSearch() { 32 | if( filter === "byYear") { 33 | if( yearFrom === null || yearTo === null ) { 34 | Alert.warning("Both year values are required") 35 | } 36 | else { 37 | props.onFilterChanged({ filter: filter, from: yearFrom, to: yearTo }) 38 | } 39 | } 40 | } 41 | 42 | // render 43 | const showYears = filter === "byYear" 44 | const showGenres = filter === "byGenre" 45 | return ( 46 |
47 | 48 | Random 49 | Newest 50 | Frequent 51 | Recent 52 | Starred 53 | By Name 54 | By Artist 55 | By Year 56 | By Genre 57 | 58 | {showGenres && } 59 | {showYears && setYearFrom(val)} placeholder="from" style={{ width: 80, marginLeft:"10px"}}/> } 60 | {showYears && setYearTo(val)} placeholder="to" style={{ width: 80, marginLeft:"10px"}}/> } 61 | {showYears && } 62 |
63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /src/components/AlbumsListFilter/AlbumsListFilter.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import AlbumsListFilter from "./AlbumsListFilter" 4 | 5 | describe("", () => { 6 | 7 | it("renders without crashing", () => { 8 | shallow( ) 9 | }) 10 | 11 | it("notifies when a basic filter is choosed", () => { 12 | const onFilterChanged = jest.fn() 13 | const filter = "newest" 14 | const wrapper = shallow( ) 15 | wrapper.find("#filterSelection").simulate("change", filter) 16 | expect(onFilterChanged).toHaveBeenCalledWith({filter: filter}) 17 | }) 18 | 19 | it("notifies when a genre is selected", () => { 20 | const onFilterChanged = jest.fn() 21 | const filter = "byGenre" 22 | const genre = {value: "blues"} 23 | const wrapper = shallow( ) 24 | wrapper.find("#filterSelection").simulate("change", filter) 25 | wrapper.find("#genrePicker").simulate("genreChanged", genre) 26 | expect(onFilterChanged).toHaveBeenCalledWith({filter: filter, genre: "blues"}) 27 | }) 28 | 29 | it("should only notify if a genre is set", () => { 30 | const onFilterChanged = jest.fn() 31 | const filter = "byGenre" 32 | const wrapper = shallow( ) 33 | wrapper.find("#filterSelection").simulate("change", filter) 34 | wrapper.find("#genrePicker").simulate("genreChanged", null) 35 | expect(onFilterChanged).toHaveBeenCalledTimes(0) 36 | }) 37 | 38 | it("notifies when the filter is set by year", () => { 39 | const onFilterChanged = jest.fn() 40 | const filter = "byYear" 41 | const wrapper = shallow( ) 42 | wrapper.find("#filterSelection").simulate("change", filter) 43 | wrapper.find("#yearFrom").simulate("change","1990") 44 | wrapper.find("#yearTo").simulate("change","1995") 45 | wrapper.find("#yearSearch").simulate("click") 46 | expect(onFilterChanged).toHaveBeenCalledWith({filter: filter, from:"1990", to:"1995"}) 47 | }) 48 | 49 | it("should only notify if both years are set", () => { 50 | const onFilterChanged = jest.fn() 51 | const filter = "byYear" 52 | const wrapper = shallow( ) 53 | wrapper.find("#filterSelection").simulate("change", filter) 54 | wrapper.find("#yearFrom").simulate("change","1990") 55 | wrapper.find("#yearSearch").simulate("click") 56 | expect(onFilterChanged).toHaveBeenCalledTimes(0) 57 | }) 58 | 59 | }) -------------------------------------------------------------------------------- /src/components/AlbumsListFilter/index.js: -------------------------------------------------------------------------------- 1 | // UI 2 | import AlbumsListFilter from './AlbumsListFilter' 3 | 4 | export default AlbumsListFilter -------------------------------------------------------------------------------- /src/components/AlertsManager/AlertsManager.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from 'prop-types' 3 | import * as alerts from "../../redux/actions/alertsActions" 4 | // UI 5 | import { Alert } from 'rsuite' 6 | 7 | export default class AlertsManager extends React.Component { 8 | 9 | componentDidUpdate(prevProps) { 10 | // Only show alerts if there is a new alert pending to show 11 | const alertToShow = this.props.alertToShow 12 | if( prevProps.alertToShow.id !== alertToShow.id){ 13 | // Check which is the correct case of alert to display 14 | switch(alertToShow.type) { 15 | case alerts.ALERT_TYPE_SUCCESS: 16 | Alert.success(alertToShow.message) 17 | break 18 | case alerts.ALERT_TYPE_WARNING: 19 | Alert.warning(alertToShow.message) 20 | break 21 | default: 22 | Alert.error(alertToShow.message) 23 | } 24 | } 25 | } 26 | 27 | render = () => null 28 | } 29 | 30 | AlertsManager.propTypes = { 31 | alertToShow : PropTypes.object 32 | } -------------------------------------------------------------------------------- /src/components/AlertsManager/AlertsManager.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import AlertsManager from "./AlertsManager" 4 | import * as alerts from "../../redux/actions/alertsActions" 5 | import { Alert } from 'rsuite' 6 | 7 | describe("", () => { 8 | 9 | it("renders without crashing", () => { 10 | shallow( ) 11 | }) 12 | 13 | it("should show a success Alert", () => { 14 | const alertToShow = { 15 | id : 1, 16 | type : alerts.ALERT_TYPE_SUCCESS, 17 | message : "message" 18 | } 19 | Alert.success = jest.fn() 20 | const enzymeWrapper = shallow( ) 21 | enzymeWrapper.setProps({alertToShow}) 22 | expect(Alert.success).toHaveBeenCalled() 23 | }) 24 | 25 | it("should show a warning Alert", () => { 26 | const alertToShow = { 27 | id : 1, 28 | type : alerts.ALERT_TYPE_WARNING, 29 | message : "message" 30 | } 31 | Alert.warning = jest.fn() 32 | const enzymeWrapper = shallow( ) 33 | enzymeWrapper.setProps({alertToShow}) 34 | expect(Alert.warning).toHaveBeenCalled() 35 | }) 36 | 37 | it("should show an error Alert", () => { 38 | const alertToShow = { 39 | id : 1, 40 | type : alerts.ALERT_TYPE_ERROR, 41 | message : "message" 42 | } 43 | Alert.error = jest.fn() 44 | const enzymeWrapper = shallow( ) 45 | enzymeWrapper.setProps({alertToShow}) 46 | expect(Alert.error).toHaveBeenCalled() 47 | }) 48 | 49 | }) -------------------------------------------------------------------------------- /src/components/AlertsManager/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | // UI 4 | import AlertsManager from './AlertsManager' 5 | 6 | const mapStateToProps = (state) => { 7 | return { 8 | alertToShow : state.alert 9 | } 10 | } 11 | 12 | export default connect( 13 | mapStateToProps, 14 | null 15 | )(AlertsManager) -------------------------------------------------------------------------------- /src/components/Artist/Artist.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from 'prop-types' 3 | // UI 4 | import { Nav, Icon } from 'rsuite' 5 | import ArtistAllSongs from '../ArtistAllSongs' 6 | import ArtistByAlbums from '../ArtistByAlbums' 7 | import ResponsiveTitle from '../ResponsiveTitle' 8 | 9 | export default class Artist extends React.Component { 10 | 11 | constructor(props) { 12 | super(props) 13 | this.state = {selectedView : Artist.KEY_ALL_SONGS} 14 | } 15 | 16 | componentDidMount() { 17 | this.props.loadOneArtist(this.props.artistId) 18 | } 19 | 20 | onViewSelected = (viewId) => { 21 | this.setState({selectedView : viewId}) 22 | } 23 | 24 | render() { 25 | const artist = this.props.artist || {} 26 | const activeView = this.state.selectedView 27 | return ( 28 |
29 |
30 | {artist != null ? artist.name : "..."} 31 | 35 |
36 | { activeView === Artist.KEY_BY_ALBUM && } 37 | { activeView === Artist.KEY_ALL_SONGS && } 38 |
39 | ) 40 | } 41 | } 42 | 43 | Artist.propTypes = { 44 | artistId : PropTypes.string.isRequired, 45 | artist : PropTypes.object, 46 | loadOneArtist : PropTypes.func.isRequired 47 | } 48 | 49 | Artist.defaultProps = { 50 | artist : {}, 51 | } 52 | 53 | Artist.KEY_ALL_SONGS = "all" 54 | Artist.KEY_BY_ALBUM = "by_album" -------------------------------------------------------------------------------- /src/components/Artist/Artist.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import Artist from "./Artist" 4 | 5 | const props = { 6 | artist : { 7 | album: ["album1", "album2"], 8 | albumCount: 2, 9 | id: "b44b14b2-0bfb-4a45-a36a-5a063568dddc", 10 | name: "Alabama Shakes", 11 | }, 12 | artistId : "b44b14b2-0bfb-4a45-a36a-5a063568dddc", 13 | loadOneArtist : () => null 14 | } 15 | 16 | describe("", () => { 17 | 18 | it("renders without crashing", () => { 19 | shallow( ) 20 | }) 21 | 22 | it("should load the artist's data when mounting", () => { 23 | const loadOneArtist = jest.fn() 24 | const enzymeWrapper = shallow( ) 25 | expect(loadOneArtist).toHaveBeenCalled() 26 | }) 27 | 28 | it("should show the ArtistAllSongs view when choosing to display all songs", () => { 29 | const enzymeWrapper = shallow( ) 30 | // Simulate it was chosen to display all the songs in one view 31 | enzymeWrapper.find("#viewSelector").simulate("select", Artist.KEY_ALL_SONGS) 32 | // Look for the View displayed 33 | const view = enzymeWrapper.find("Connect(ArtistAllSongs)") 34 | expect( view ).toHaveLength(1) 35 | expect( view.prop("artistId") ).toEqual(props.artist.id) 36 | }) 37 | 38 | it("should show the ArtistByAlbums view when choosing to display songs by album", () => { 39 | const enzymeWrapper = shallow( ) 40 | // Simulate it was chosen to display songs by album in one view 41 | enzymeWrapper.find("#viewSelector").simulate("select", Artist.KEY_BY_ALBUM) 42 | // Look for the View displayed 43 | const view = enzymeWrapper.find("Connect(ArtistByAlbums)") 44 | expect( view ).toHaveLength(1) 45 | expect( view.prop("artistId") ).toEqual(props.artist.id) 46 | }) 47 | 48 | 49 | }) -------------------------------------------------------------------------------- /src/components/Artist/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { loadOneArtist } from "../../redux/actions/artistsActions" 4 | // UI 5 | import Artist from './Artist' 6 | 7 | const mapStateToProps = (state, props) => { 8 | return { 9 | artist : state.artists.byId[props.artistId], 10 | } 11 | } 12 | 13 | const mapDispatchToProps = { loadOneArtist } 14 | 15 | export default connect( 16 | mapStateToProps, 17 | mapDispatchToProps 18 | )(Artist) -------------------------------------------------------------------------------- /src/components/ArtistAllSongs/ArtistAllSongs.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from 'prop-types' 3 | // UI 4 | import SongsTableEnhanced from '../SongsTableEnhanced' 5 | import SongsTable from '../SongsTable/SongsTable' 6 | 7 | const COLUMNS_TO_SHOW = [SongsTable.columns.title, SongsTable.columns.album, SongsTable.columns.duration, SongsTable.columns.bitRate, SongsTable.columns.selectable, SongsTable.columns.download] 8 | 9 | export default function ArtistAllSongs(props) { 10 | const songs = props.songs 11 | return ( 12 | 13 | ) 14 | } 15 | 16 | ArtistAllSongs.propTypes = { 17 | style : PropTypes.object, 18 | songs : PropTypes.array, 19 | } 20 | 21 | ArtistAllSongs.defaultProps = { 22 | style : {}, 23 | songs : [], 24 | } 25 | -------------------------------------------------------------------------------- /src/components/ArtistAllSongs/ArtistAllSongs.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import ArtistAllSongs from "./ArtistAllSongs" 4 | 5 | const props = { 6 | style : { 7 | flexGrow : 1 8 | }, 9 | songs : [ 10 | { 11 | "album": "21", 12 | "albumId": "506fe2e8-ee8d-4d12-b61d-4c0b96608f1c", 13 | "artist": "Adele", 14 | "artistId": "c299a083-4f82-4288-805f-0e97a9bb39a8", 15 | "bitRate": 320, 16 | "contentType": "audio/mpeg", 17 | "coverArt": "d5d99cde-4653-4182-bb80-5655019a6a58", 18 | "created": "2019-07-29T23:29:11", 19 | "discNumber": 1, 20 | "duration": 228, 21 | "genre": "Pop", 22 | "isDir": false, 23 | "isVideo": false, 24 | "parent": "f57575f2-08b4-450a-a1ce-9defda3c3234", 25 | "path": "Adele/21/01 - Rolling in the Deep.mp3", 26 | "size": 9849609, 27 | "starred": "2019-08-01T04:23:28", 28 | "suffix": "mp3", 29 | "title": "Rolling in the Deep", 30 | "track": 1, 31 | "type": "music", 32 | "year": 2011 33 | }, 34 | { 35 | "album": "21", 36 | "albumId": "506fe2e8-ee8d-4d12-b61d-4c0b96608f1c", 37 | "artist": "Adele", 38 | "artistId": "c299a083-4f82-4288-805f-0e97a9bb39a8", 39 | "bitRate": 320, 40 | "contentType": "audio/mpeg", 41 | "coverArt": "8cc7604f-88d0-4664-857c-93516290424e", 42 | "created": "2019-07-29T23:28:59", 43 | "discNumber": 1, 44 | "duration": 285, 45 | "genre": "Pop", 46 | "isDir": false, 47 | "isVideo": false, 48 | "parent": "f57575f2-08b4-450a-a1ce-9defda3c3234", 49 | "path": "Adele/21/11 - Someone Like You.mp3", 50 | "size": 12121397, 51 | "starred": "2019-08-01T04:23:28", 52 | "suffix": "mp3", 53 | "title": "Someone Like You", 54 | "track": 11, 55 | "type": "music", 56 | "year": 2011 57 | } 58 | ] 59 | } 60 | 61 | describe("", () => { 62 | 63 | it("renders without crashing", () => { 64 | shallow( ) 65 | }) 66 | 67 | it("should show a table with the desired songs", () => { 68 | const enzymeWrapper = shallow( ) 69 | expect( enzymeWrapper.find("Connect(SongsTableEnhanced)").prop("songs") ).toEqual(props.songs) 70 | }) 71 | 72 | }) -------------------------------------------------------------------------------- /src/components/ArtistAllSongs/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { songsOfArtistSelector } from '../../redux/selectors/songSelectors' 4 | // UI 5 | import ArtistAllSongs from './ArtistAllSongs' 6 | 7 | const mapStateToProps = (state, props) => { 8 | return { 9 | songs: songsOfArtistSelector(state, props), 10 | } 11 | } 12 | 13 | export default connect( 14 | mapStateToProps, 15 | null 16 | )(ArtistAllSongs) -------------------------------------------------------------------------------- /src/components/ArtistByAlbums/ArtistByAlbums.less: -------------------------------------------------------------------------------- 1 | 2 | .nav-artist-by-albums { 3 | flex : 0 0 230px; 4 | overflow: auto; 5 | } 6 | 7 | .nav-artist-by-albums .album-element { 8 | white-space: nowrap; 9 | overflow: hidden; 10 | text-overflow: ellipsis; 11 | } 12 | 13 | .artists-by-albums-container { 14 | overflow: auto; 15 | } 16 | 17 | @media (max-width: 767px) { 18 | .artists-by-albums-container { 19 | overflow: initial; 20 | } 21 | } -------------------------------------------------------------------------------- /src/components/ArtistByAlbums/ArtistByAlbums.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import ArtistByAlbums from "./ArtistByAlbums" 4 | 5 | const props = { 6 | albums : [ 7 | { 8 | artist: "Arctic Monkeys", 9 | artistId: "be9da6bf-32f1-459e-a00f-518e70c9fcbe", 10 | coverArt: "de18b3b9-9a3c-45f6-ac6b-a04f50dd0fc4", 11 | created: "2010-04-09T05:26:56", 12 | duration: 2456, 13 | id: "b96e45a4-b665-4e81-9138-454274cda106", 14 | name: "Whatever People Say I Am, That", 15 | song: ["song1", "song2"], 16 | songCount: 2, 17 | }, 18 | { 19 | artist: "Arctic Monkeys", 20 | artistId: "be9da6bf-32f1-459e-a00f-518e70c9fcbe", 21 | coverArt: "8b7eb6ef-db1c-4240-bf09-fa57aeef16fa", 22 | created: "2010-04-09T05:32:06", 23 | duration: 2254, 24 | id: "bbbff36f-dfc9-401b-9b46-883c80d6ea82", 25 | name: "Favourite Worst Nightmare", 26 | song: ["song3"], 27 | songCount: 1, 28 | } 29 | ] 30 | } 31 | 32 | describe("", () => { 33 | 34 | it("renders without crashing", () => { 35 | shallow( ) 36 | }) 37 | 38 | it("should show a Select for small displays to pick an album", () => { 39 | const wrapper = shallow( ) 40 | const albumsSelectData = props.albums.map(album => ({ value : album.id, label : album.name })) 41 | expect( wrapper.find("#selectAlbums").prop("data") ).toEqual(albumsSelectData) 42 | }) 43 | 44 | it("should show NavItems for large displays to pick an album", () => { 45 | const wrapper = shallow( ) 46 | // There should be a NavItem per album 47 | expect( wrapper.find(`[eventKey='${props.albums[0].id}']`) ).toHaveLength(1) 48 | expect( wrapper.find(`[eventKey='${props.albums[1].id}']`) ).toHaveLength(1) 49 | }) 50 | 51 | it("should show the correct album when selected an album on small displays", () => { 52 | const wrapper = shallow( ) 53 | // Simulate an album was selected 54 | const albumId = props.albums[0].id 55 | wrapper.find("withLocale(defaultProps(SelectPicker))").simulate("change", albumId) 56 | // Look for the Album displayed 57 | expect( wrapper.find("#album").prop("albumId") ).toEqual(albumId) 58 | }) 59 | 60 | it("should show the correct album when selected an album on large displays", () => { 61 | const wrapper = shallow( ) 62 | // Simulate an album was selected 63 | const albumId = props.albums[0].id 64 | wrapper.find(".nav-artist-by-albums").simulate("select", albumId) 65 | // Look for the Album displayed 66 | expect( wrapper.find("#album").prop("albumId") ).toEqual(albumId) 67 | }) 68 | 69 | }) -------------------------------------------------------------------------------- /src/components/ArtistByAlbums/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { getAlbumsOfArtist } from "../../redux/selectors/albumSelectors" 4 | // UI 5 | import ArtistByAlbums from './ArtistByAlbums' 6 | 7 | const mapStateToProps = (state, props) => { 8 | return { 9 | albums : getAlbumsOfArtist(state, props), 10 | } 11 | } 12 | 13 | export default connect( 14 | mapStateToProps, 15 | null 16 | )(ArtistByAlbums) -------------------------------------------------------------------------------- /src/components/ArtistListElement/ArtistListElement.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from 'prop-types' 3 | import { navigate } from "@reach/router" 4 | // UI 5 | import "./ArtistListElement.less" 6 | import { Icon, Col } from 'rsuite' 7 | 8 | export default class ArtistListElement extends React.Component { 9 | 10 | shouldComponentUpdate(nextProps, nextState) { 11 | let shouldReRender = true 12 | // Avoid re-rendering if the current artist playing didnt change 13 | if( nextProps.currentSongPlaying !== null && 14 | nextProps.currentSongPlaying.id !== this.props.currentSongPlaying.id ) { 15 | // Check if this artists needs to be re-rendered 16 | const nextArtistId = nextProps.currentSongPlaying.artistId 17 | const currentArtistId = this.props.currentSongPlaying.artistId 18 | shouldReRender = false 19 | // Check if this artist used to have it and now it doesnt 20 | if( this.props.artist.id === currentArtistId && this.props.artist.id !== nextArtistId ) { 21 | shouldReRender = true 22 | } 23 | // Check if this artist used not to be here and now it does 24 | if( this.props.artist.id !== currentArtistId && this.props.artist.id === nextArtistId ) { 25 | shouldReRender = true 26 | } 27 | } 28 | return shouldReRender 29 | } 30 | 31 | render() { 32 | const currentArtistPlayingId = this.props.currentSongPlaying && this.props.currentSongPlaying.artistId 33 | const artist = this.props.artist 34 | return ( 35 | {navigate("/artists/"+artist.id)} }> 36 | {artist.name} 37 | 38 | ) 39 | } 40 | } 41 | 42 | ArtistListElement.propTypes = { 43 | currentSongPlaying : PropTypes.object, 44 | artist : PropTypes.object, 45 | } 46 | 47 | ArtistListElement.defaultProps = { 48 | currentSongPlaying : {}, 49 | artist : {} 50 | } 51 | -------------------------------------------------------------------------------- /src/components/ArtistListElement/ArtistListElement.less: -------------------------------------------------------------------------------- 1 | .link_to_artist { 2 | padding : 15px 20px; 3 | transition: color .3s linear, background-color .3s linear; 4 | transition-property: color, background-color; 5 | transition-duration: 0.3s, 0.3s; 6 | transition-timing-function: linear, linear; 7 | transition-delay: 0s, 0s; 8 | } 9 | 10 | .link_to_artist i { 11 | display: none; 12 | } 13 | 14 | .link_to_artist.currently-playing i { 15 | display: initial; 16 | } -------------------------------------------------------------------------------- /src/components/ArtistListElement/ArtistListElement.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import ArtistListElement from "./ArtistListElement" 4 | 5 | const currentSongPlaying = { 6 | artistId : "myid" 7 | } 8 | 9 | const artist = { 10 | id : "myid", 11 | name : "my name" 12 | } 13 | 14 | describe("", () => { 15 | 16 | it("renders without crashing", () => { 17 | shallow( ) 18 | }) 19 | 20 | it("should render the specified artist correctly", () => { 21 | const wrapper = shallow( ) 22 | const artists = wrapper.find("#name") 23 | expect(wrapper.find("#name").html()).toContain( "my name" ) 24 | }) 25 | 26 | it("should contain an indicator of the artist currently playing", () => { 27 | const wrapper = shallow( ) 28 | expect(wrapper.find("#name.currently-playing").html()).toContain( "my name" ) 29 | // Change the currently playing artist and expect the class "playing" to not be present 30 | wrapper.setProps({currentSongPlaying : {artistId : "2"} }) 31 | expect(wrapper.find("#name.currently-playing").exists()).toBeFalsy() 32 | }) 33 | 34 | }) -------------------------------------------------------------------------------- /src/components/ArtistListElement/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { getSongCurrentlyPlayingSelector } from '../../redux/selectors/musicPlayerSelector' 4 | // UI 5 | import ArtistListElement from './ArtistListElement' 6 | 7 | const mapStateToProps = (state, ownProps) => { 8 | return { 9 | currentSongPlaying : getSongCurrentlyPlayingSelector(state), 10 | } 11 | } 12 | 13 | export default connect( 14 | mapStateToProps, 15 | null 16 | )(ArtistListElement) -------------------------------------------------------------------------------- /src/components/ArtistListHeader/ArtistListHeader.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from 'prop-types' 3 | // UI 4 | import { Col } from 'rsuite' 5 | 6 | function ArtistListHeader(props) { 7 | const { name } = props 8 | return ( 9 | 10 |

{name}

11 | 12 | ) 13 | } 14 | 15 | ArtistListHeader.propTypes = { 16 | name : PropTypes.string 17 | } 18 | 19 | export default ArtistListHeader -------------------------------------------------------------------------------- /src/components/ArtistListHeader/ArtistListHeader.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import ArtistListHeader from "./ArtistListHeader" 4 | 5 | describe("", () => { 6 | 7 | it("renders without crashing", () => { 8 | shallow( ) 9 | }) 10 | 11 | it("should load a title with the header", () => { 12 | const wrapper = shallow( ) 13 | expect( wrapper.find("#title").text() ).toEqual("my test") 14 | }) 15 | 16 | }) -------------------------------------------------------------------------------- /src/components/ArtistListHeader/index.js: -------------------------------------------------------------------------------- 1 | // UI 2 | import ArtistListHeader from './ArtistListHeader' 3 | 4 | export default ArtistListHeader -------------------------------------------------------------------------------- /src/components/ArtistListLoader/ArtistListLoader.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | // UI 3 | import { Col } from 'rsuite' 4 | 5 | function ArtistListLoader(props) { 6 | return ( 7 | Loading... 8 | ) 9 | } 10 | 11 | export default ArtistListLoader -------------------------------------------------------------------------------- /src/components/ArtistListLoader/ArtistListLoader.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import ArtistListLoader from "./ArtistListLoader" 4 | 5 | describe("", () => { 6 | 7 | it("renders without crashing", () => { 8 | shallow( ) 9 | }) 10 | 11 | }) -------------------------------------------------------------------------------- /src/components/ArtistListLoader/index.js: -------------------------------------------------------------------------------- 1 | // UI 2 | import ArtistListLoader from './ArtistListLoader' 3 | 4 | export default ArtistListLoader -------------------------------------------------------------------------------- /src/components/ArtistsList/ArtistsList.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import { ArtistsList, getNextArtists } from "./ArtistsList" 4 | import { getArtistsWithHeaders } from "../../redux/selectors/artistSelectors" 5 | 6 | // Mock a list of mock artists 7 | const props = { 8 | artists : getArtistsWithHeaders( 9 | { 10 | artists : { 11 | byIndex : 12 | ['A','B','C','D','E','F','G','H','I','J','K','L','M','N','O','P','Q','R','S','T','U','V','W','X','Y','Z'] 13 | .map(i => { 14 | return { 15 | name:i, 16 | artist : [1,2,3,4,5].map(n => { 17 | return { id : i+n, name : n } 18 | }) 19 | } 20 | }) 21 | } 22 | } 23 | ) 24 | } 25 | 26 | describe("", () => { 27 | 28 | it("renders without crashing", () => { 29 | shallow( ) 30 | }) 31 | 32 | it("should load the artists if there are not loaded", () => { 33 | const loadArtists = jest.fn() 34 | shallow( ) 35 | expect( loadArtists ).toHaveBeenCalled() 36 | }) 37 | 38 | it("should take the artists from cache if they are already loaded", () => { 39 | const loadArtists = jest.fn() 40 | shallow( ) 41 | expect( loadArtists ).toHaveBeenCalledTimes(0) 42 | }) 43 | 44 | it("should contain an infinite scroll to load all the artists", () => { 45 | const enzymeWrapper = shallow( ) 46 | expect( enzymeWrapper.find("InfiniteScroll")).toHaveLength(1) 47 | }) 48 | 49 | it("should be able to paginate artists", () => { 50 | const pageSize = 5 51 | const firstPage = getNextArtists(0, props.artists, pageSize) 52 | expect( firstPage.length ).toEqual(7) 53 | const secondPage = getNextArtists(firstPage.length, props.artists, pageSize) 54 | expect( firstPage.length ).toEqual(7) 55 | }) 56 | 57 | }) -------------------------------------------------------------------------------- /src/components/ArtistsList/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { loadArtists } from "../../redux/actions/artistsActions" 4 | import { getArtistsWithHeaders } from "../../redux/selectors/artistSelectors" 5 | // UI 6 | import {ArtistsList} from './ArtistsList' 7 | 8 | const mapStateToProps = (state) => { 9 | return { 10 | artists: getArtistsWithHeaders(state), 11 | } 12 | } 13 | 14 | const mapDispatchToProps = { loadArtists } 15 | 16 | export default connect( 17 | mapStateToProps, 18 | mapDispatchToProps 19 | )(ArtistsList) -------------------------------------------------------------------------------- /src/components/AuthenticatedComponent/AuthenticatedComponent.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from 'prop-types' 3 | import { Redirect } from "@reach/router" 4 | // UI 5 | import { Loader } from 'rsuite'; 6 | 7 | export default class AuthenticatedComponent extends React.Component { 8 | 9 | componentDidMount() { 10 | // Check if we need to lazy login 11 | if( !this.props.isAuthenticated ) { 12 | this.props.lazyLoginUser && this.props.lazyLoginUser() 13 | } 14 | } 15 | 16 | render() { 17 | const authenticatingView = (
) 18 | return ( 19 |
20 | { 21 | this.props.isAuthenticating === true 22 | ? authenticatingView : ( 23 | this.props.isAuthenticated === true 24 | ? this.props.children 25 | : 26 | ) 27 | } 28 |
29 | ) 30 | 31 | } 32 | } 33 | 34 | AuthenticatedComponent.propTypes = { 35 | isAuthenticated : PropTypes.bool.isRequired, 36 | isAuthenticating : PropTypes.bool.isRequired, 37 | lazyLoginUser : PropTypes.func 38 | } 39 | 40 | AuthenticatedComponent.defaultProps = { 41 | isAuthenticated : false, 42 | isAuthenticating : false, 43 | } 44 | -------------------------------------------------------------------------------- /src/components/AuthenticatedComponent/AuthenticatedComponent.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import AuthenticatedComponent from "./AuthenticatedComponent" 4 | 5 | describe("", () => { 6 | 7 | it("renders without crashing", () => { 8 | shallow( ) 9 | }) 10 | 11 | it("should try to lazy log in when the user is not authenticated", () => { 12 | const lazyLoginUser = jest.fn() 13 | const enzymeWrapper = shallow( ) 14 | expect( lazyLoginUser ).toHaveBeenCalled() 15 | }) 16 | 17 | it("should not render anything when authenticating", () => { 18 | const enzymeWrapper = shallow( ) 19 | expect( enzymeWrapper.find("#loading") ).toHaveLength(1) 20 | }) 21 | 22 | it("should redirect to log in when not authenticated", () => { 23 | const enzymeWrapper = shallow( ) 24 | expect( enzymeWrapper.find("Redirect") ).toHaveLength(1) 25 | }) 26 | 27 | it("should render children when authenticated", () => { 28 | const enzymeWrapper = shallow( 29 | 30 |
31 | 32 | ) 33 | expect( enzymeWrapper.find("#child") ).toHaveLength(1) 34 | }) 35 | 36 | }) -------------------------------------------------------------------------------- /src/components/AuthenticatedComponent/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { lazyLoginUser } from "../../redux/actions/authActions" 4 | // UI 5 | import AuthenticatedComponent from './AuthenticatedComponent' 6 | 7 | const mapStateToProps = (state) => ({ 8 | isAuthenticated: state.auth.isAuthenticated, 9 | isAuthenticating: state.auth.isAuthenticating 10 | }) 11 | 12 | const mapDispatchToProps = { lazyLoginUser } 13 | 14 | export default connect( 15 | mapStateToProps, 16 | mapDispatchToProps 17 | )(AuthenticatedComponent) -------------------------------------------------------------------------------- /src/components/CreatePlaylistModal/CreatePlaylistModal.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | // UI 4 | import { Button, Modal, Form, FormGroup, FormControl, ControlLabel } from 'rsuite' 5 | 6 | export default class CreatePlaylistModal extends React.Component { 7 | 8 | constructor(props) { 9 | super(props) 10 | this.state = { playlistNameErrorMessage : null } 11 | this.newPlaylist = {name : ""} 12 | } 13 | 14 | closeModal = () => { 15 | this.props.onClosePlaylistModal && this.props.onClosePlaylistModal() 16 | } 17 | 18 | onPlaylistFormChange = (value) => { 19 | this.newPlaylist = value 20 | } 21 | 22 | closeModalAndCreate = () => { 23 | if( this.newPlaylist.name.length > 0 ) { 24 | this.setState({playlistNameErrorMessage : null}) 25 | this.props.createPlaylist( this.newPlaylist.name ) 26 | this.closeModal() 27 | } 28 | else { 29 | this.setState({playlistNameErrorMessage : "Name required"}) 30 | } 31 | } 32 | 33 | handleKeyDown = (e) => { 34 | if( e.key === "Enter" ) { 35 | this.closeModalAndCreate() 36 | } 37 | } 38 | 39 | render() { 40 | return ( 41 | 42 | 43 | New Playlist 44 | 45 | 46 |
47 | 48 | Name 49 | 50 | 51 |
52 |
53 | 54 | 55 | 56 | 57 |
58 | ) 59 | } 60 | } 61 | 62 | CreatePlaylistModal.propTypes = { 63 | showModal : PropTypes.bool.isRequired, 64 | onClosePlaylistModal : PropTypes.func, 65 | createPlaylist : PropTypes.func.isRequired, 66 | } 67 | 68 | CreatePlaylistModal.defaultProps = { 69 | showModal : false, 70 | createPlaylist : () => (null), 71 | } 72 | -------------------------------------------------------------------------------- /src/components/CreatePlaylistModal/CreatePlaylistModal.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import CreatePlaylistModal from "./CreatePlaylistModal" 4 | 5 | 6 | describe("", () => { 7 | 8 | it("renders without crashing", () => { 9 | shallow( ) 10 | }) 11 | 12 | it("should not show the modal when not wanted", () => { 13 | const wrapper = shallow( ) 14 | expect(wrapper.find("#modal").prop("show")).toBe(false) 15 | }) 16 | 17 | it("should show the modal when wanted", () => { 18 | const wrapper = shallow( ) 19 | expect(wrapper.find("#modal").prop("show")).toBe(true) 20 | }) 21 | 22 | it("should show error messages when playlist form is invalid", () => { 23 | // Mock playlist creation function 24 | const createPlaylist = jest.fn() 25 | // Mount 26 | const wrapper = shallow( ) 27 | wrapper.find("#create_playlist").simulate("click") 28 | // Test form is not submitted and error messages are shown 29 | expect( createPlaylist ).toHaveBeenCalledTimes(0) 30 | expect(typeof wrapper.find("[name='name']").prop("errorMessage")).toBe("string") 31 | }) 32 | 33 | it("should trigger playlist creation when form is valid", () => { 34 | // Mock playlist creation and modal reaction function 35 | const createPlaylist = jest.fn() 36 | const onClosePlaylistModal = jest.fn() 37 | // Mount 38 | const wrapper = shallow( ) 39 | wrapper.find("Form").simulate("change", { 40 | name : "name" 41 | }) 42 | wrapper.find("#create_playlist").simulate("click") 43 | // Test form is submitted and modal is hidden 44 | expect( createPlaylist ).toHaveBeenCalledTimes(1) 45 | expect( onClosePlaylistModal ).toHaveBeenCalledTimes(1) 46 | }) 47 | 48 | it("should close the modal on close button clicked", () => { 49 | const createPlaylist = jest.fn() 50 | const onClosePlaylistModal = jest.fn() 51 | const wrapper = shallow( ) 52 | wrapper.find("#close").simulate("click") 53 | expect( onClosePlaylistModal ).toHaveBeenCalledTimes(1) 54 | expect( createPlaylist ).toHaveBeenCalledTimes(0) 55 | }) 56 | 57 | }) -------------------------------------------------------------------------------- /src/components/CreatePlaylistModal/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { createPlaylist } from "../../redux/actions/playlistsActions" 4 | // UI 5 | import CreatePlaylistModal from './CreatePlaylistModal' 6 | 7 | const mapDispatchToProps = { createPlaylist } 8 | 9 | export default connect( 10 | null, 11 | mapDispatchToProps 12 | )(CreatePlaylistModal) -------------------------------------------------------------------------------- /src/components/DeletePlaylistModal/DeletePlaylistModal.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { navigate } from "@reach/router" 3 | import PropTypes from 'prop-types' 4 | // UI 5 | import { Button, Modal, Icon, Input } from 'rsuite' 6 | 7 | export default class DeletePlaylistModal extends React.Component { 8 | 9 | constructor(props) { 10 | super(props) 11 | this.waitingForDeletion = false 12 | this.state = {deleteNameError : false} 13 | this.confirmation_name = "" 14 | } 15 | 16 | componentDidUpdate(prevProps) { 17 | // Check if the playlist was deleted: it doesnt exist and we dispatched a deletion request 18 | // Alternative: Navigate on deletion without waiting for confirmation 19 | if(!this.props.playlist && this.waitingForDeletion ) { 20 | navigate("/") 21 | } 22 | } 23 | 24 | closeModalAndDelete = () => { 25 | // validate playlist name 26 | if( this.confirmation_name === this.props.playlist.name ) { 27 | this.waitingForDeletion = true 28 | this.props.deletePlaylist(this.props.playlist) 29 | this.closeDeleteModal() 30 | } 31 | else { 32 | this.setState({deleteNameError : true}) 33 | } 34 | } 35 | 36 | closeDeleteModal = () => { 37 | this.props.onHide && this.props.onHide() 38 | } 39 | 40 | render() { 41 | const playlistToDelete = this.props.playlist || {} 42 | return ( 43 | 44 | 45 | 46 | {' '} 47 | Once a playlist is deleted, it can't be recovered. If you want to proceed, write the name of the playlist "{playlistToDelete.name}": 48 | {this.confirmation_name = value})} style={{width:"100%", marginTop:"10px"}} /> 49 | {this.state.deleteNameError ? Name does not match : null} 50 | 51 | 52 | 53 | 54 | 55 | 56 | ) 57 | } 58 | } 59 | 60 | DeletePlaylistModal.propTypes = { 61 | onHide: PropTypes.func, 62 | deletePlaylist: PropTypes.func.isRequired, 63 | playlistId : PropTypes.string.isRequired 64 | } 65 | -------------------------------------------------------------------------------- /src/components/DeletePlaylistModal/DeletePlaylistModal.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import DeletePlaylistModal from "./DeletePlaylistModal" 4 | 5 | describe("", () => { 6 | 7 | const playlist = { 8 | "id" : "my_id", 9 | "name" : "my playlist", 10 | "owner" : "the owner", 11 | "songCount" : 24, 12 | "duration" : 729, 13 | "comment" : "this is a comment" 14 | } 15 | 16 | it("renders without crashing", () => { 17 | shallow( null}/> ) 18 | }) 19 | 20 | it("should let me delete a playlist", () => { 21 | const deletePlaylist = jest.fn() 22 | const wrapper = shallow( ) 23 | // Try to delete the playlist 24 | wrapper.find("#confirm_name").simulate("change", "my playlist") 25 | wrapper.find("#deleteButton").simulate("click") 26 | // Playlist should be deleted 27 | expect(wrapper.find("#errorMessage").exists()).toBeFalsy() 28 | expect(deletePlaylist).toHaveBeenCalledTimes(1) 29 | }) 30 | 31 | it("should not let me delete a playlist without confirmation", () => { 32 | const deletePlaylist = jest.fn() 33 | const wrapper = shallow( ) 34 | // Try to delete the playlist without confirmation 35 | wrapper.find("#deleteButton").simulate("click") 36 | // Playlist should not be deleted and an error message should be displayed 37 | expect(wrapper.find("#errorMessage").exists()).toBeTruthy() 38 | expect(deletePlaylist).toHaveBeenCalledTimes(0) 39 | }) 40 | 41 | it("should let me cancel the deletion", () => { 42 | const deletePlaylist = jest.fn() 43 | const wrapper = shallow( ) 44 | // Try to cancel the operation 45 | wrapper.find("#cancelButton").simulate("click") 46 | // The deletion should not be triggered 47 | expect(deletePlaylist).toHaveBeenCalledTimes(0) 48 | }) 49 | 50 | }) -------------------------------------------------------------------------------- /src/components/DeletePlaylistModal/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { deletePlaylist } from "../../redux/actions/playlistsActions" 4 | // UI 5 | import DeletePlaylistModal from './DeletePlaylistModal' 6 | 7 | const mapStateToProps = (state, ownProps) => { 8 | return { 9 | "playlist" : state.playlists.byId[ownProps.playlistId], 10 | } 11 | } 12 | 13 | const mapDispatchToProps = { deletePlaylist } 14 | 15 | export default connect( 16 | mapStateToProps, 17 | mapDispatchToProps 18 | )(DeletePlaylistModal) -------------------------------------------------------------------------------- /src/components/EditPlaylistModal/EditPlaylistModal.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import EditPlaylistModal from "./EditPlaylistModal" 4 | 5 | describe("", () => { 6 | 7 | const playlist = { 8 | "id" : "my_id", 9 | "name" : "my playlist", 10 | "comment" : "this is a comment", 11 | "isPublic" : true 12 | } 13 | 14 | it("renders without crashing", () => { 15 | shallow( null}/> ) 16 | }) 17 | 18 | it("should let me edit a playlist", () => { 19 | const editPlaylist = jest.fn() 20 | const wrapper = shallow( ) 21 | // Try to edit the playlist 22 | wrapper.find("#name").simulate("change", "new name") 23 | wrapper.find("#comment").simulate("change", "new comment") 24 | wrapper.find("#isPublic").simulate("change", null, false) 25 | wrapper.find("#editButton").simulate("click") 26 | // Playlist should be edited 27 | expect(editPlaylist).toHaveBeenCalledWith("my_id", "new name", "new comment", false) 28 | }) 29 | 30 | it("should not let me edit a playlist without a name", () => { 31 | const editPlaylist = jest.fn() 32 | const wrapper = shallow( ) 33 | // Try to edit the playlist 34 | wrapper.find("#name").simulate("change", "") 35 | wrapper.find("#editButton").simulate("click") 36 | // Playlist should not be edited and an error message should be displayed 37 | expect(wrapper.find("#nameErrorMessage").exists()).toBeTruthy() 38 | expect(editPlaylist).toHaveBeenCalledTimes(0) 39 | }) 40 | 41 | 42 | it("should let me cancel the edition", () => { 43 | const editPlaylist = jest.fn() 44 | const wrapper = shallow( ) 45 | // Try to cancel the operation 46 | wrapper.find("#cancelButton").simulate("click") 47 | // The edition should not be triggered 48 | expect(editPlaylist).toHaveBeenCalledTimes(0) 49 | }) 50 | 51 | }) -------------------------------------------------------------------------------- /src/components/EditPlaylistModal/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { editPlaylist } from "../../redux/actions/playlistsActions" 4 | // UI 5 | import EditPlaylistModal from './EditPlaylistModal' 6 | 7 | const mapStateToProps = (state, ownProps) => { 8 | return { 9 | "playlist" : state.playlists.byId[ownProps.playlistId], 10 | } 11 | } 12 | 13 | const mapDispatchToProps = { editPlaylist } 14 | 15 | export default connect( 16 | mapStateToProps, 17 | mapDispatchToProps 18 | )(EditPlaylistModal) -------------------------------------------------------------------------------- /src/components/FavouritesView/FavouritesView.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from 'prop-types' 3 | // Utils 4 | import { seconds_to_hhmmss } from "../../utils/formatting.js" 5 | // UI 6 | import SongsTableEnhanced from '../SongsTableEnhanced' 7 | import { Button } from 'rsuite' 8 | import SongsTable from '../SongsTable/SongsTable' 9 | 10 | const COLUMNS_TO_SHOW = [SongsTable.columns.selectable, SongsTable.columns.title, SongsTable.columns.artist, SongsTable.columns.album, SongsTable.columns.duration, SongsTable.columns.bitRate, SongsTable.columns.download, SongsTable.columns.starred] 11 | 12 | export default class FavouritesView extends React.Component { 13 | 14 | constructor(props) { 15 | super(props) 16 | this.state = { selectedSongs : [], duration : 0 } 17 | } 18 | 19 | componentDidMount() { 20 | this.props.loadFavouriteSongs() 21 | } 22 | 23 | componentDidUpdate(prevProps) { 24 | if( prevProps.songs.length !== this.props.songs.length ) { 25 | this.setState({selectedSongs: [], duration : this.props.songs.reduce( (a,b) => ({duration: a.duration+b.duration}), {duration:0} ).duration}) 26 | } 27 | } 28 | 29 | /* Listeners */ 30 | 31 | onSongsSelected = (selectedSongs) => { 32 | this.setState({selectedSongs: selectedSongs}) 33 | } 34 | 35 | removeSelectedSongs = () => { 36 | if( this.state.selectedSongs.length > 0 ) { 37 | this.props.setStarOnSongs(this.state.selectedSongs, false) 38 | } 39 | } 40 | 41 | render() { 42 | const songs = this.props.songs 43 | const duration = this.state.duration 44 | const disableButton = this.state.selectedSongs && this.state.selectedSongs.length === 0 45 | return ( 46 |
47 |
48 |
49 |

Favourites

50 | { songs.length } songs, {seconds_to_hhmmss(duration)} 51 |
52 |
53 | 54 |
55 |
56 | 57 |
58 | ) 59 | } 60 | } 61 | 62 | FavouritesView.propTypes = { 63 | loadFavouriteSongs : PropTypes.func.isRequired, 64 | setStarOnSongs : PropTypes.func, 65 | songs : PropTypes.array, 66 | 67 | } 68 | 69 | FavouritesView.defaultProps = { 70 | songs : [] 71 | } 72 | -------------------------------------------------------------------------------- /src/components/FavouritesView/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { loadFavouriteSongs, setStarOnSongs } from "../../redux/actions/favouritesActions" 4 | import { favouriteSongsSelector } from '../../redux/selectors/songSelectors' 5 | // UI 6 | import FavouritesView from './FavouritesView' 7 | 8 | const mapStateToProps = (state, ownProps) => { 9 | return { 10 | "songs" : favouriteSongsSelector(state) 11 | } 12 | } 13 | 14 | const mapDispatchToProps = { loadFavouriteSongs, setStarOnSongs } 15 | 16 | export default connect( 17 | mapStateToProps, 18 | mapDispatchToProps 19 | )(FavouritesView) -------------------------------------------------------------------------------- /src/components/GenreSongs/GenreSongs.js: -------------------------------------------------------------------------------- 1 | import React, {useEffect} from 'react' 2 | import PropTypes from 'prop-types' 3 | // UI 4 | import SongsTable from '../SongsTable/SongsTable' 5 | import SongsTableEnhanced from '../SongsTableEnhanced' 6 | 7 | const COLUMNS_TO_SHOW = [SongsTable.columns.title, SongsTable.columns.artist, SongsTable.columns.album, SongsTable.columns.duration, SongsTable.columns.bitRate, SongsTable.columns.download, SongsTable.columns.selectable] 8 | 9 | export default function GenreSongs(props) { 10 | const { genre, loadSongsOfGenre, songs, ...rest } = props 11 | 12 | useEffect(()=> { 13 | if( genre !== null) { 14 | loadSongsOfGenre(genre) 15 | } 16 | }, [genre, loadSongsOfGenre]) 17 | 18 | return ( 19 | 20 | ) 21 | } 22 | 23 | GenreSongs.propTypes = { 24 | genre: PropTypes.object, 25 | loadSongsOfGenre: PropTypes.func, 26 | songs : PropTypes.array, 27 | } 28 | 29 | GenreSongs.defaultProps = { 30 | genre: null, 31 | loadSongsOfGenre: () => null, 32 | songs: [] 33 | } 34 | -------------------------------------------------------------------------------- /src/components/GenreSongs/GenreSongs.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import GenreSongs from "./GenreSongs" 4 | 5 | describe("", () => { 6 | 7 | it("renders without crashing", () => { 8 | shallow( ) 9 | }) 10 | 11 | it("should load songs when a genre is selected", () => { 12 | const loadSongsOfGenre = jest.fn() 13 | const wrapper = shallow( ) 14 | const genre = {value:"blues"} 15 | // change the genre 16 | wrapper.setProps({genre}) 17 | expect(loadSongsOfGenre).toHaveBeenCalledWith(genre) 18 | }) 19 | 20 | }) -------------------------------------------------------------------------------- /src/components/GenreSongs/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { loadSongsOfGenre } from "../../redux/actions/genresActions" 4 | import { songsOfGenreSelector } from '../../redux/selectors/songSelectors' 5 | // UI 6 | import GenreSongs from './GenreSongs' 7 | 8 | const mapStateToProps = (state, ownProps) => { 9 | return { 10 | "songs" : songsOfGenreSelector(state, ownProps) 11 | } 12 | } 13 | 14 | const mapDispatchToProps = { loadSongsOfGenre } 15 | 16 | export default connect( 17 | mapStateToProps, 18 | mapDispatchToProps 19 | )(GenreSongs) -------------------------------------------------------------------------------- /src/components/GenresPicker/GenresPicker.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect} from "react" 2 | import PropTypes from 'prop-types' 3 | // utils 4 | import subsonic from "../../api/subsonicApi" 5 | import {SelectPicker } from 'rsuite' 6 | 7 | export default function GenresPicker(props) { 8 | const {beginAsyncTask, asyncTaskSuccess, asyncTaskError, onGenreChanged, displaySongCount, ...rest} = props 9 | const [genres, setGenres] = useState([]) 10 | 11 | // Get the available genres 12 | useEffect(() => { 13 | const getGenres = async () => { 14 | // Get the available genres 15 | beginAsyncTask() 16 | try { 17 | const result = await subsonic.getGenres() 18 | asyncTaskSuccess() 19 | setGenres( result.map(g => ({label:`${g.value} (${displaySongCount ? g.songCount : g.albumCount})`, value:g})) ) 20 | } 21 | catch(err) { 22 | console.log(err) 23 | asyncTaskError("Unable to get genres") 24 | } 25 | } 26 | getGenres() 27 | }, [beginAsyncTask, asyncTaskSuccess, asyncTaskError, displaySongCount, setGenres]) 28 | 29 | // Notify when the value has changed 30 | const onValueChanged = (genre) => onGenreChanged(genre) 31 | 32 | return ( 33 | 34 | ) 35 | } 36 | 37 | GenresPicker.propTypes = { 38 | beginAsyncTask: PropTypes.func, 39 | asyncTaskSuccess: PropTypes.func, 40 | asyncTaskError: PropTypes.func, 41 | onGenreChanged: PropTypes.func, 42 | displaySongCount: PropTypes.bool 43 | } 44 | 45 | GenresPicker.defaultProps = { 46 | beginAsyncTask: ()=> null, 47 | asyncTaskSuccess: ()=> null, 48 | asyncTaskError: ()=> null, 49 | onGenreChanged: ()=> null, 50 | displaySongCount: false, 51 | } 52 | -------------------------------------------------------------------------------- /src/components/GenresPicker/GenresPicker.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import GenresPicker from "./GenresPicker" 4 | 5 | describe("", () => { 6 | 7 | it("renders without crashing", () => { 8 | shallow( ) 9 | }) 10 | 11 | it("notifies when a genre changes", () => { 12 | const onGenreChanged = jest.fn() 13 | const wrapper = shallow( ) 14 | // change the genre 15 | wrapper.find("#genrePicker").simulate("change", "genre") 16 | expect(onGenreChanged).toHaveBeenCalledWith("genre") 17 | }) 18 | 19 | }) -------------------------------------------------------------------------------- /src/components/GenresPicker/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import {beginAsyncTask, asyncTaskSuccess, asyncTaskError } from "../../redux/actions/apiStatusActions" 4 | // UI 5 | import GenresPicker from './GenresPicker' 6 | 7 | const mapDispatchToProps = { beginAsyncTask, asyncTaskSuccess, asyncTaskError } 8 | 9 | export default connect( 10 | null, 11 | mapDispatchToProps 12 | )(GenresPicker) -------------------------------------------------------------------------------- /src/components/GenresView/GenresView.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react' 2 | // UI 3 | import ResponsiveTitle from '../ResponsiveTitle' 4 | import GenreSongs from '../GenreSongs/' 5 | import GenresPicker from "../GenresPicker" 6 | import { Col } from 'rsuite' 7 | 8 | export default function GenresView(props) { 9 | const [genre, setGenre] = useState({}) 10 | 11 | return ( 12 |
13 |
14 | 15 | Genres 16 | 17 | 18 | 19 | 20 |
21 |
22 | 23 |
24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/components/GenresView/GenresView.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import GenresView from "./GenresView" 4 | 5 | describe("", () => { 6 | 7 | it("renders without crashing", () => { 8 | shallow( ) 9 | }) 10 | 11 | }) -------------------------------------------------------------------------------- /src/components/GenresView/index.js: -------------------------------------------------------------------------------- 1 | import GenresView from './GenresView' 2 | 3 | export default GenresView -------------------------------------------------------------------------------- /src/components/InfiniteLineLoader/InfiniteLineLoader.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import "./InfiniteLineLoader.less" 4 | 5 | function InfiniteLineLoader(props) { 6 | const display = props.isLoading ? "initial" : "none" 7 | return ( 8 |
9 | ) 10 | } 11 | 12 | InfiniteLineLoader.propTypes = { 13 | isLoading : PropTypes.bool 14 | } 15 | 16 | InfiniteLineLoader.defaultProps = { 17 | isLoading : false 18 | } 19 | 20 | export default InfiniteLineLoader -------------------------------------------------------------------------------- /src/components/InfiniteLineLoader/InfiniteLineLoader.less: -------------------------------------------------------------------------------- 1 | .loader { 2 | height: 4px; 3 | width: 100%; 4 | position: relative; 5 | overflow: hidden; 6 | } 7 | 8 | .loader:before{ 9 | display: block; 10 | position: absolute; 11 | content: ""; 12 | left: -200px; 13 | width: 200px; 14 | height: 4px; 15 | animation: loading 2s linear infinite; 16 | } 17 | 18 | @keyframes loading { 19 | from {left: -200px; width: 30%;} 20 | 50% {width: 30%;} 21 | 70% {width: 70%;} 22 | 80% { left: 50%;} 23 | 95% {left: 120%;} 24 | to {left: 100%;} 25 | } 26 | -------------------------------------------------------------------------------- /src/components/InfiniteLineLoader/InfiniteLineLoader.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import InfiniteLineLoader from "./InfiniteLineLoader" 4 | 5 | describe("", () => { 6 | 7 | it("renders without crashing", () => { 8 | shallow( ) 9 | }) 10 | 11 | it("should show the loader on demand", () => { 12 | const wrapper = shallow( ) 13 | const style = wrapper.find(".loader").prop("style") 14 | expect(style.display).toBe("initial") 15 | }) 16 | 17 | it("should hide the loader on demand", () => { 18 | const wrapper = shallow( ) 19 | const style = wrapper.find(".loader").prop("style") 20 | expect(style.display).toBe("none") 21 | }) 22 | 23 | }) -------------------------------------------------------------------------------- /src/components/InfiniteLineLoader/index.js: -------------------------------------------------------------------------------- 1 | import InfiniteLineLoader from "./InfiniteLineLoader" 2 | 3 | export default InfiniteLineLoader -------------------------------------------------------------------------------- /src/components/LoginView/LoginView.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import LoginView from "./LoginView" 4 | import { navigate } from "@reach/router" 5 | 6 | jest.mock('@reach/router', () => ({ 7 | navigate: jest.fn(), 8 | })) 9 | 10 | const mockedSubmitEvent = { 11 | target: {}, 12 | stopPropagation : () => null, 13 | preventDefault : () => null, 14 | } 15 | 16 | describe("", () => { 17 | 18 | it("renders without crashing", () => { 19 | shallow( ) 20 | }) 21 | 22 | it("should try to lazy log in when the user is not authenticated", () => { 23 | const lazyLoginUser = jest.fn() 24 | const enzymeWrapper = shallow( ) 25 | expect( lazyLoginUser ).toHaveBeenCalled() 26 | }) 27 | 28 | it("should redirect to home when the user is authenticated", () => { 29 | const enzymeWrapper = shallow( ) 30 | expect( navigate ).toHaveBeenCalled() 31 | }) 32 | 33 | it("should show error messages when login form is invalid", () => { 34 | // Mock functions and submit event 35 | const loginUser = jest.fn() 36 | // Mount 37 | const enzymeWrapper = shallow( ) 38 | enzymeWrapper.find("Form").simulate("submit", mockedSubmitEvent) 39 | // Test form is not submitted and error messages are shown 40 | expect( loginUser ).toHaveBeenCalledTimes(0) 41 | expect(typeof enzymeWrapper.find("[name='host']").prop("errorMessage")).toBe("string") 42 | expect(typeof enzymeWrapper.find("[name='username']").prop("errorMessage")).toBe("string") 43 | expect(typeof enzymeWrapper.find("[name='password']").prop("errorMessage")).toBe("string") 44 | }) 45 | 46 | it("should trigger login when form is valid", () => { 47 | // Mock functions and submit event 48 | const loginUser = jest.fn() 49 | // Test form is submitted when setting values to fields 50 | const enzymeWrapper = shallow( ) 51 | enzymeWrapper.find("Form").simulate("change", { 52 | host : "host", 53 | username : "username", 54 | password : "password" 55 | }) 56 | enzymeWrapper.find("Form").simulate("submit", mockedSubmitEvent) 57 | expect( loginUser ).toHaveBeenCalledTimes(1) 58 | }) 59 | 60 | }) -------------------------------------------------------------------------------- /src/components/LoginView/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { loginUser, lazyLoginUser } from "../../redux/actions/authActions" 4 | // UI 5 | import LoginView from './LoginView' 6 | 7 | const mapStateToProps = (state) => ({ 8 | isAuthenticating : state.auth.isAuthenticating, 9 | isAuthenticated: state.auth.isAuthenticated, 10 | statusText : state.auth.statusText, 11 | }) 12 | 13 | const mapDispatchToProps = { loginUser, lazyLoginUser } 14 | 15 | export default connect( 16 | mapStateToProps, 17 | mapDispatchToProps 18 | )(LoginView) -------------------------------------------------------------------------------- /src/components/MusicPlayer/MusicPlayer.less: -------------------------------------------------------------------------------- 1 | .music-player { 2 | display: flex; 3 | flex-flow: row nowrap; 4 | min-height: 65px; 5 | justify-content: space-around; 6 | align-items: center; 7 | padding-right: 30px; 8 | padding-left: 30px; 9 | } 10 | 11 | /* 12 | * Styles of the song metadata container which includes: 13 | * - album cover 14 | * - song name 15 | * - song artist 16 | * - fav star 17 | */ 18 | 19 | /* For XS (mobile) screens, we need to make this container smaller 20 | * (which will result in showing less of the song's name) to make 21 | * room for the currently_playing_controls which require at least 90px. 22 | * To avoid the queue button to look cramped, we also need to reduce 23 | * the paddings 24 | */ 25 | @media (max-width: 767px) { 26 | .music-player { 27 | padding-right: 5px; 28 | padding-left: 10px; 29 | } 30 | 31 | .music-player .song_metadata_container { 32 | width: 210px !important; 33 | } 34 | } 35 | 36 | .music-player .song_metadata_container { 37 | width: 255px; 38 | align-items: center; 39 | display: flex; 40 | flex-flow: row; 41 | } 42 | 43 | .music-player .song_metadata_container img { 44 | margin-right: 10px; 45 | cursor: pointer; 46 | } 47 | 48 | 49 | .music-player .song_metadata_container p { 50 | white-space: nowrap; 51 | overflow: hidden; 52 | text-overflow: ellipsis; 53 | } 54 | 55 | .music-player .song_metadata_container .artist-link { 56 | cursor: pointer; 57 | } 58 | 59 | .music-player .song_metadata_container .artist-link:hover { 60 | text-decoration: underline; 61 | } 62 | 63 | /* 64 | * Styles of the currently playing controls which include: 65 | * - play previous song 66 | * - play/pause current song 67 | * - play next song 68 | */ 69 | 70 | .music-player .currently_playing_controls i { 71 | width: 90px; 72 | flex: 0 0 90px; 73 | } 74 | 75 | /* 76 | * Styles of the song progress bar which include: 77 | * - current position (minutes:seconds) 78 | * - Progress bar 79 | * - total song's duration (minutes:seconds) 80 | */ 81 | 82 | .music-player .song_progress_bar_container { 83 | display: flex; 84 | align-items: center; 85 | } 86 | 87 | .music-player .song_progress_bar_container span { 88 | margin: 0 10px; 89 | } 90 | 91 | .music-player .song_progress_bar_container .song_progress_bar { 92 | flex-grow: 1; 93 | cursor: pointer; 94 | } 95 | 96 | .song_progress_bar { 97 | flex-grow: 1; 98 | } 99 | 100 | 101 | /* 102 | * Styles of the volume controls which include: 103 | * - muting button 104 | * - volume bar 105 | */ 106 | 107 | .music-player .volume_controls_container { 108 | display: flex; 109 | flex-direction: row; 110 | align-items: center; 111 | } 112 | 113 | .music-player .volume_control_bar { 114 | width: 100px; 115 | } 116 | 117 | .music-player i.volume_control_mute { 118 | margin-right: 15px; 119 | margin-left: 5px; 120 | } 121 | -------------------------------------------------------------------------------- /src/components/MusicPlayer/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { playNextSong, playPreviousSong, toggleShuffle } from "../../redux/actions/songsActions" 4 | import { setStarOnSongs } from "../../redux/actions/favouritesActions" 5 | import { getSongCurrentlyPlayingSelector } from '../../redux/selectors/musicPlayerSelector' 6 | // UI 7 | import MusicPlayer from './MusicPlayer' 8 | 9 | const mapStateToProps = (state) => { 10 | return { 11 | "song" : getSongCurrentlyPlayingSelector(state), 12 | "isShuffleOn": state.musicPlayer.isShuffleOn, 13 | } 14 | } 15 | 16 | const mapDispatchToProps = { playNextSong, playPreviousSong, setStarOnSongs, toggleShuffle } 17 | 18 | export default connect( 19 | mapStateToProps, 20 | mapDispatchToProps 21 | )(MusicPlayer) -------------------------------------------------------------------------------- /src/components/Navbar/Navbar.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { navigate } from "@reach/router" 4 | // Utils 5 | import * as settings from "../../utils/settings.js" 6 | // UI 7 | import {Navbar, Icon, Nav, Dropdown } from 'rsuite' 8 | 9 | export default class MyNavbar extends React.Component { 10 | 11 | constructor(props) { 12 | super(props) 13 | // Check which items to display in the settings 14 | this.itemsToDisplay = settings.getSidebarDisplaySettings(true) 15 | } 16 | 17 | onNavSelected = (key) => { 18 | switch(key) { 19 | case "newPlaylist": 20 | this.props.onCreatePlaylistTrigger && this.props.onCreatePlaylistTrigger() 21 | break 22 | default: 23 | navigate(key) 24 | } 25 | } 26 | 27 | render() { 28 | const playlists = this.props.playlists 29 | const currentPath = this.props.currentLocation 30 | return ( 31 | 32 | 33 | 51 | 54 | 55 | 56 | ) 57 | } 58 | } 59 | 60 | MyNavbar.propTypes = { 61 | onCreatePlaylistTrigger : PropTypes.func, 62 | playlists : PropTypes.object.isRequired 63 | } 64 | 65 | MyNavbar.defaultProps = { 66 | playlists : {} 67 | } 68 | -------------------------------------------------------------------------------- /src/components/Navbar/Navbar.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import MyNavbar from "./Navbar" 4 | 5 | describe("", () => { 6 | 7 | it("renders without crashing", () => { 8 | shallow( ) 9 | }) 10 | 11 | it("renders the playlists with their amount of songs", () => { 12 | const playlists = { 13 | "id1" : { name : "one", songCount : 20}, 14 | "id2" : { name : "two", songCount : 132}, 15 | } 16 | const wrapper = shallow( ) 17 | // check there are the 2 playlists + create 18 | expect(wrapper.find("#playlists").children()).toHaveLength(3) 19 | }) 20 | 21 | // TODO: couldn't find a way to test the contents of each playlist item to check if they are rendered correctly 22 | 23 | // TODO: couldn't find a way to test the nav items as component.simulate('click') doesn't trigger anything 24 | 25 | }) 26 | -------------------------------------------------------------------------------- /src/components/Navbar/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Location } from "@reach/router" 3 | // Redux 4 | import { connect } from "react-redux" 5 | // UI 6 | import Navbar from './Navbar' 7 | 8 | /* Create a Navbar connected to Reach's location provider */ 9 | function HOCNavbar(props) { 10 | const navbarProps = props 11 | return ( 12 | 13 | { 14 | props => { 15 | // Get the location from reach's to highlight the active item 16 | const currentPath = props.location.pathname 17 | return 18 | } 19 | } 20 | 21 | ) 22 | } 23 | 24 | const mapStateToProps = (state) => { 25 | return { 26 | "playlists" : state.playlists.byId, 27 | } 28 | } 29 | 30 | export default connect( 31 | mapStateToProps, 32 | null 33 | )(HOCNavbar) -------------------------------------------------------------------------------- /src/components/Playlist/Playlist.less: -------------------------------------------------------------------------------- 1 | .playlist-songs-container { 2 | padding-right: 15px; 3 | padding-left: 15px; 4 | padding-bottom: 15px; 5 | } 6 | 7 | /* For XS (mobile) screens, we need to make this container smaller 8 | * (which will result in showing less of the song's name) to make 9 | * room for the currently_playing_controls which require at least 90px. 10 | * To avoid the queue button to look cramped, we also need to reduce 11 | * the paddings 12 | */ 13 | @media (max-width: 767px) { 14 | .playlist-songs-container { 15 | padding: 0px; 16 | } 17 | } -------------------------------------------------------------------------------- /src/components/Playlist/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { removeSongsFromPlaylist, loadSinglePlaylist } from "../../redux/actions/playlistsActions" 4 | import { songsOfPlaylistSelector } from '../../redux/selectors/songSelectors' 5 | // UI 6 | import Playlist from './Playlist' 7 | 8 | const mapStateToProps = (state, ownProps) => { 9 | return { 10 | "playlist" : state.playlists.byId[ownProps.playlistId], 11 | "songs" : songsOfPlaylistSelector(state, ownProps) 12 | } 13 | } 14 | 15 | const mapDispatchToProps = { removeSongsFromPlaylist, loadSinglePlaylist } 16 | 17 | export default connect( 18 | mapStateToProps, 19 | mapDispatchToProps 20 | )(Playlist) -------------------------------------------------------------------------------- /src/components/PlaylistSelectorDropdown/PlaylistSelectorDropdown.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from 'prop-types' 3 | // UI 4 | import { Dropdown, Icon } from 'rsuite' 5 | 6 | export default class PlaylistSelectorDropdown extends React.Component { 7 | 8 | constructor(props) { 9 | super(props) 10 | // Create a random key for favourites to avoid constraining the names of the playlists 11 | // i.e. if we assign eventKey="favs", then a playlist could not be named "favs" 12 | this.favourites_key = `${Math.random()}` 13 | this.queue_key = `${Math.random()}` 14 | } 15 | 16 | onItemSelected = (eventKey) => { 17 | if(this.props.playlists[eventKey]) { 18 | this.props.onPlaylistSelected && this.props.onPlaylistSelected(this.props.playlists[eventKey]) 19 | } 20 | else if( this.favourites_key === eventKey ) { 21 | this.props.onFavouritesSelected && this.props.onFavouritesSelected() 22 | } 23 | else { 24 | this.props.onQueueSelected && this.props.onQueueSelected() 25 | } 26 | } 27 | 28 | render() { 29 | const showFavourites = this.props.showFavourites 30 | const showQueue = this.props.showQueue 31 | return ( 32 | 33 | { 34 | showFavourites ? 35 | }>Favourites 36 | : null 37 | } 38 | { 39 | showQueue ? 40 | }>Queue 41 | : null 42 | } 43 | { 44 | showFavourites ? : null 45 | } 46 | { 47 | Object.keys(this.props.playlists).map(pId => 48 | this.props.playlists[pId].isMine ? 49 | {this.props.playlists[pId].name} 50 | : null 51 | ) 52 | } 53 | 54 | ) 55 | } 56 | 57 | } 58 | 59 | PlaylistSelectorDropdown.propTypes = { 60 | playlists : PropTypes.object.isRequired, 61 | onFavouritesSelected : PropTypes.func, 62 | onPlaylistSelected : PropTypes.func, 63 | showFavourites : PropTypes.bool.isRequired, 64 | showQueue : PropTypes.bool.isRequired 65 | } 66 | 67 | PlaylistSelectorDropdown.defaultProps = { 68 | playlists : {}, 69 | showFavourites : true, 70 | showQueue : true, 71 | } 72 | -------------------------------------------------------------------------------- /src/components/PlaylistSelectorDropdown/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | // UI 4 | import PlaylistSelectorDropdown from './PlaylistSelectorDropdown' 5 | 6 | const mapStateToProps = (state) => { 7 | return { 8 | "playlists" : state.playlists.byId, 9 | } 10 | } 11 | 12 | export default connect( 13 | mapStateToProps, 14 | null 15 | )(PlaylistSelectorDropdown) -------------------------------------------------------------------------------- /src/components/QueueView/QueueView.less: -------------------------------------------------------------------------------- 1 | .queue-songs-container { 2 | padding-right: 15px; 3 | padding-left: 15px; 4 | padding-bottom: 15px; 5 | } 6 | 7 | /* For XS (mobile) screens, we need to make this container smaller 8 | * (which will result in showing less of the song's name) to make 9 | * room for the currently_playing_controls which require at least 90px. 10 | * To avoid the queue button to look cramped, we also need to reduce 11 | * the paddings 12 | */ 13 | @media (max-width: 767px) { 14 | .queue-songs-container { 15 | padding: 0px; 16 | } 17 | } -------------------------------------------------------------------------------- /src/components/QueueView/QueueView.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import Queue from "./QueueView" 4 | 5 | describe("", () => { 6 | 7 | const songs = [ 8 | { "id" : "1" }, 9 | { "id" : "2" }, 10 | ] 11 | 12 | it("renders without crashing", () => { 13 | shallow( ) 14 | }) 15 | 16 | it("should clear the queue on click", () => { 17 | const clearQueue = jest.fn() 18 | const wrapper = shallow( ) 19 | wrapper.find("#clear_button").simulate("click") 20 | expect(clearQueue).toHaveBeenCalledTimes(1) 21 | }) 22 | 23 | it("should remove songs on click", () => { 24 | const removeSongsFromQueue = jest.fn() 25 | const wrapper = shallow( ) 26 | // Select some songs 27 | const selectedSongs = [songs[0]] 28 | wrapper.find("#songs_table").simulate("songsSelected", selectedSongs) 29 | // Remove them from the queue 30 | wrapper.find("#remove_button").simulate("click") 31 | expect(removeSongsFromQueue).toHaveBeenCalledWith(selectedSongs) 32 | }) 33 | 34 | it("should play any song in the queue", () => { 35 | const seekToSongInQueue = jest.fn() 36 | const wrapper = shallow( ) 37 | // Click a song 38 | const songClicked = songs[0] 39 | wrapper.find("#songs_table").simulate("songClicked", songClicked) 40 | // Expect it to now play 41 | expect(seekToSongInQueue).toHaveBeenCalledWith(songClicked) 42 | }) 43 | 44 | }) -------------------------------------------------------------------------------- /src/components/QueueView/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { clearQueue, removeSongsFromQueue, seekToSongInQueue } from "../../redux/actions/songsActions" 4 | import { getSongsInQueueSelector } from '../../redux/selectors/musicPlayerSelector' 5 | // UI 6 | import QueueView from './QueueView' 7 | 8 | const mapStateToProps = (state, ownProps) => { 9 | return { 10 | "songs" : getSongsInQueueSelector(state, ownProps) 11 | } 12 | } 13 | 14 | const mapDispatchToProps = { clearQueue, removeSongsFromQueue, seekToSongInQueue } 15 | 16 | export default connect( 17 | mapStateToProps, 18 | mapDispatchToProps 19 | )(QueueView) -------------------------------------------------------------------------------- /src/components/RecentlyAddedView/RecentlyAddedView.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from 'prop-types' 3 | // Utils 4 | import subsonic from "../../api/subsonicApi" 5 | // UI 6 | import { Grid, Row, Col } from 'rsuite' 7 | import AlbumResult from "../SearchAlbumResult" 8 | import "./RecentlyAddedView.less" 9 | 10 | export default class RecentlyAddedView extends React.Component { 11 | 12 | constructor(props) { 13 | super(props) 14 | this.state = {albums : []} 15 | } 16 | 17 | componentDidMount = async () => { 18 | // Get the newest albums from Subsonic 19 | this.props.beginAsyncTask() 20 | try { 21 | const albums = await subsonic.getAlbumList2("newest") 22 | this.props.asyncTaskSuccess() 23 | this.setState({albums}) 24 | } 25 | catch(err) { 26 | console.log(err) 27 | this.props.asyncTaskError("Unable to load recently added albums") 28 | } 29 | } 30 | 31 | render() { 32 | const albums = this.state.albums 33 | return ( 34 | 35 | 36 | 37 |

Recently Added

38 | 39 | 40 | { albums.map(a => 41 |
42 | 43 |
44 | )} 45 | 46 |
47 |
48 | ) 49 | } 50 | } 51 | 52 | RecentlyAddedView.propTypes = { 53 | beginAsyncTask: PropTypes.func, 54 | asyncTaskSuccess: PropTypes.func, 55 | asyncTaskError : PropTypes.func, 56 | } 57 | 58 | RecentlyAddedView.defaultProps = { 59 | beginAsyncTask: () => null, 60 | asyncTaskSuccess: () => null, 61 | asyncTaskError : () => null, 62 | } 63 | -------------------------------------------------------------------------------- /src/components/RecentlyAddedView/RecentlyAddedView.less: -------------------------------------------------------------------------------- 1 | .result-grid-container { 2 | display: flex; 3 | flex-flow: row wrap; 4 | justify-content: center; 5 | } 6 | 7 | .result-grid-container .result-item { 8 | flex-basis: 16.6%; 9 | -ms-flex: auto; 10 | position: relative; 11 | padding: 10px; 12 | box-sizing: border-box; 13 | } -------------------------------------------------------------------------------- /src/components/RecentlyAddedView/RecentlyAddedView.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import RecentlyAddedView from "./RecentlyAddedView" 4 | // Mocking redux 5 | import configureMockStore from 'redux-mock-store' 6 | import thunk from 'redux-thunk' 7 | import fetchMock from 'fetch-mock' 8 | 9 | const middlewares = [thunk] 10 | const mockStore = configureMockStore(middlewares) 11 | 12 | describe("", () => { 13 | 14 | afterEach(() => { 15 | fetchMock.restore() 16 | }) 17 | 18 | it("renders without crashing", () => { 19 | shallow( ) 20 | }) 21 | 22 | it("should load the latest albums and update API calls' status", () => { 23 | // Mock API call 24 | const mockResponse = {"subsonic-response": {"albumList2": {"album" : []}, "status": "ok","version": "1.9.0"}} 25 | fetchMock.getOnce('*', { 26 | body: mockResponse, 27 | headers: { 'content-type': 'application/json' } 28 | }) 29 | // Mount component 30 | const beginAsyncTask = jest.fn() 31 | const asyncTaskSuccess = jest.fn() 32 | shallow( ) 33 | // Expect calls on success 34 | expect(beginAsyncTask).toHaveBeenCalledTimes(1) 35 | // TODO: how to test this? This probably means that SubsonicAPI should be mocked separately 36 | // or that the api call should be made somewhere else 37 | // expect(asyncTaskSuccess).toHaveBeenCalledTimes(1) 38 | }) 39 | 40 | }) -------------------------------------------------------------------------------- /src/components/RecentlyAddedView/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import {beginAsyncTask, asyncTaskSuccess, asyncTaskError } from "../../redux/actions/apiStatusActions" 4 | // UI 5 | import RecentlyAddedView from './RecentlyAddedView' 6 | 7 | const mapDispatchToProps = { beginAsyncTask, asyncTaskSuccess, asyncTaskError } 8 | 9 | export default connect( 10 | null, 11 | mapDispatchToProps 12 | )(RecentlyAddedView) -------------------------------------------------------------------------------- /src/components/ResponsiveTitle/ResponsiveTitle.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from 'prop-types' 3 | 4 | function ResponsiveTitle(props) { 5 | return ( 6 | <> 7 |

{props.children}

8 |

{props.children}

9 | 10 | ) 11 | } 12 | 13 | ResponsiveTitle.propTypes = { 14 | style : PropTypes.object, 15 | children : PropTypes.any 16 | } 17 | 18 | export default ResponsiveTitle 19 | 20 | -------------------------------------------------------------------------------- /src/components/ResponsiveTitle/ResponsiveTitle.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import ResponsiveTitle from "./ResponsiveTitle" 4 | 5 | describe("", () => { 6 | 7 | it("renders without crashing", () => { 8 | shallow( ) 9 | }) 10 | 11 | }) -------------------------------------------------------------------------------- /src/components/ResponsiveTitle/index.js: -------------------------------------------------------------------------------- 1 | // UI 2 | import ResponsiveTitle from './ResponsiveTitle' 3 | 4 | export default ResponsiveTitle -------------------------------------------------------------------------------- /src/components/ScrobbleSetting/ScrobbleSetting.js: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from "react" 2 | import { Toggle } from 'rsuite' 3 | // settings 4 | import * as settings from "../../utils/settings" 5 | 6 | export default function ScrobbleSetting(props) { 7 | const [value, setValue] = useState(true) 8 | 9 | // load from settings 10 | useEffect(() => { 11 | const isScrobbling = settings.getIsScrobbling() 12 | setValue(isScrobbling) 13 | }, []) 14 | 15 | // update settings 16 | function update_settings(value) { 17 | settings.setIsScrobbling(value) 18 | setValue(value) 19 | } 20 | 21 | return ( 22 |
23 | Scrobble? 24 |
25 | ) 26 | } -------------------------------------------------------------------------------- /src/components/ScrobbleSetting/ScrobbleSetting.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { shallow } from 'enzyme' 3 | import ScrobbleSetting from "./ScrobbleSetting" 4 | 5 | describe("", () => { 6 | 7 | it("renders without crashing", () => { 8 | shallow( ) 9 | }) 10 | 11 | }) -------------------------------------------------------------------------------- /src/components/ScrobbleSetting/index.js: -------------------------------------------------------------------------------- 1 | // UI 2 | import ScrobbleSetting from './ScrobbleSetting' 3 | 4 | export default ScrobbleSetting -------------------------------------------------------------------------------- /src/components/SearchAlbumResult/SearchAlbumResult.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { navigate } from "@reach/router" 3 | import PropTypes from 'prop-types' 4 | 5 | import { Icon, Badge } from 'rsuite' 6 | import subsonic from "../../api/subsonicApi" 7 | 8 | import "./SearchAlbumResult.less" 9 | 10 | export default function SearchAlbumResult(props) { 11 | const {album, showYear} = props 12 | return ( 13 |
{navigate("/album/"+album.id)} }> 14 | : false}> 15 |
16 | 17 |
18 |
19 |

20 | {album.name}
21 | {album.artist} 22 | { showYear && ` (${album.year})` } 23 |

24 |
25 | ) 26 | } 27 | 28 | SearchAlbumResult.propTypes = { 29 | album : PropTypes.object.isRequired, 30 | showYear: PropTypes.bool 31 | } 32 | 33 | SearchAlbumResult.defaultProps = { 34 | showYear: false 35 | } -------------------------------------------------------------------------------- /src/components/SearchAlbumResult/SearchAlbumResult.less: -------------------------------------------------------------------------------- 1 | .link_to_album { 2 | padding : 10px 5px; 3 | transition: color .3s linear, background-color .3s linear; 4 | transition-property: color, background-color; 5 | transition-duration: 0.3s, 0.3s; 6 | transition-timing-function: linear, linear; 7 | transition-delay: 0s, 0s; 8 | } 9 | 10 | /* Remove padding for small screens */ 11 | @media (max-width: 767px) { 12 | .link_to_album { 13 | padding: 0px; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/components/SearchAlbumResult/SearchAlbumResult.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import SearchAlbumResult from "./SearchAlbumResult" 4 | 5 | describe("", () => { 6 | 7 | it("renders without crashing", () => { 8 | const album = {id : "a1", name : "Album", artist : "Artist"} 9 | shallow( ) 10 | }) 11 | 12 | it("renders an indicator for starred albums without crashing", () => { 13 | const album = {id : "a1", name : "Album", artist : "Artist", starred:true} 14 | shallow( ) 15 | }) 16 | 17 | it("shows the year of the album", () => { 18 | const album = {id : "a1", name : "Album", artist : "Artist", year:"2020"} 19 | const wrapper = shallow( ) 20 | expect(wrapper.find("[data-key='description']").text().includes(album.year)).toBeTruthy() 21 | }) 22 | 23 | }) -------------------------------------------------------------------------------- /src/components/SearchAlbumResult/index.js: -------------------------------------------------------------------------------- 1 | import SearchAlbumResult from './SearchAlbumResult' 2 | 3 | export default SearchAlbumResult -------------------------------------------------------------------------------- /src/components/SearchBar/SearchBar.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from 'prop-types' 3 | // UI 4 | import { Input, InputGroup, Icon } from 'rsuite' 5 | 6 | export default class SearchBar extends React.PureComponent { 7 | 8 | performSearch = () => { 9 | this.props.onSearch && this.props.onSearch(this.query) 10 | } 11 | 12 | handleKeyDown = (e) => { 13 | if( e.key === "Enter" ) { 14 | this.performSearch() 15 | } 16 | } 17 | 18 | render() { 19 | return ( 20 | 21 | {this.query = value})} onKeyDown={this.handleKeyDown} /> 22 | 23 | 24 | ) 25 | } 26 | } 27 | 28 | SearchBar.propTypes = { 29 | size : PropTypes.string, 30 | onSearch : PropTypes.func.isRequired 31 | } 32 | 33 | SearchBar.defaultProps = { 34 | size : "lg", 35 | } 36 | -------------------------------------------------------------------------------- /src/components/SearchBar/SearchBar.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import SearchBar from "./SearchBar" 4 | 5 | describe("", () => { 6 | 7 | it("renders without crashing", () => { 8 | shallow( null} /> ) 9 | }) 10 | 11 | it("should let me perform a search", () => { 12 | const onSearch = jest.fn() 13 | const wrapper = shallow( ) 14 | // Try to query something 15 | wrapper.find("#queryBar").simulate("change", "my query") 16 | wrapper.find("#searchButton").simulate("click") 17 | // Check that the search function is called 18 | expect(onSearch).toHaveBeenCalledWith("my query") 19 | }) 20 | 21 | }) -------------------------------------------------------------------------------- /src/components/SearchBar/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { navigate } from "@reach/router" 3 | // Redux 4 | import { connect } from "react-redux" 5 | import { search } from "../../redux/actions/searchActions" 6 | // UI 7 | import SearchBar from './SearchBar' 8 | 9 | /* Create a SearchBar connected to the store to automatically perform searches in the App */ 10 | class HOCSearchBar extends React.Component { 11 | 12 | performSearch = (query) => { 13 | if( query ) { 14 | this.props.search(query) 15 | navigate("/search") 16 | } 17 | } 18 | 19 | render = () => 20 | } 21 | 22 | export default connect(null, { search })(HOCSearchBar) -------------------------------------------------------------------------------- /src/components/SearchSongResult/SearchSongResult.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | // UI 4 | import SongsTable from '../SongsTable/SongsTable' 5 | import SongsTableEnhanced from '../SongsTableEnhanced' 6 | 7 | const SONG_COLUMNS_TO_SHOW = [SongsTable.columns.selectable, SongsTable.columns.title, SongsTable.columns.artist, SongsTable.columns.album, SongsTable.columns.duration, SongsTable.columns.bitRate, SongsTable.columns.download] 8 | 9 | export default function SearchSongResult(props) { 10 | const songs = props.songs 11 | return ( 12 | 13 | ) 14 | } 15 | 16 | SearchSongResult.propTypes = { 17 | songs : PropTypes.array.isRequired 18 | } -------------------------------------------------------------------------------- /src/components/SearchSongResult/SearchSongResult.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import SearchSongResult from "./SearchSongResult" 4 | 5 | describe("", () => { 6 | 7 | it("renders without crashing", () => { 8 | const songs = [{id : 1}] 9 | shallow( ) 10 | }) 11 | 12 | }) -------------------------------------------------------------------------------- /src/components/SearchSongResult/index.js: -------------------------------------------------------------------------------- 1 | import SearchSongResult from './SearchSongResult' 2 | 3 | export default SearchSongResult -------------------------------------------------------------------------------- /src/components/SearchView/SearchView.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import PropTypes from 'prop-types' 3 | // Results 4 | import AlbumResult from "../SearchAlbumResult" 5 | import ArtistElement from "../ArtistListElement" 6 | import SongsResult from "../SearchSongResult" 7 | // UI 8 | import SearchBar from "../SearchBar" 9 | import { Col } from 'rsuite' 10 | 11 | export default class SearchView extends React.Component { 12 | 13 | render() { 14 | const albums = this.props.albums || [] 15 | const artists = this.props.artists || [] 16 | const songs = this.props.songs || [] 17 | return ( 18 |
19 | 20 | 21 | 22 | 23 | { 24 | /* Case where there are no results */ 25 | (artists.length === 0 && albums.length === 0 && songs.length === 0) 26 | ?

No results

27 | : null 28 | } 29 | 30 | { 31 | /* Artists section */ 32 | artists.length > 0 ? 33 | 34 |

Artists

35 |
36 | { artists.map( a => )} 37 |
38 |
39 | : null 40 | } 41 | 42 | { 43 | albums.length > 0 ? ( 44 | 45 |

Albums

46 |
47 | { albums.map(a => 48 |
49 | 50 |
51 | )} 52 |
53 |
54 | ) : null 55 | } 56 | 57 | { 58 | songs.length > 0 ? ( 59 | 60 |

Songs

61 | 62 |
63 | ) : null 64 | } 65 | 66 |
67 | ) 68 | } 69 | } 70 | 71 | SearchView.propTypes = { 72 | "artists" : PropTypes.array, 73 | "albums" : PropTypes.array, 74 | "songs" : PropTypes.array, 75 | } 76 | -------------------------------------------------------------------------------- /src/components/SearchView/SearchView.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import SearchView from "./SearchView" 4 | 5 | describe("", () => { 6 | 7 | it("renders without crashing when no results are available", () => { 8 | shallow( ) 9 | }) 10 | 11 | it("renders without crashing when results are available", () => { 12 | const artists = [{id : "a1", name : "artist1"}] 13 | const albums = [{id : "a1", name : "album1"}] 14 | const songs = [{id : "s1", name : "song1"}] 15 | shallow( ) 16 | }) 17 | 18 | }) -------------------------------------------------------------------------------- /src/components/SearchView/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { search } from "../../redux/actions/searchActions" 4 | import { searchSongsSelector } from '../../redux/selectors/searchSelectors' 5 | // UI 6 | import SearchView from './SearchView' 7 | 8 | const mapStateToProps = (state, ownProps) => { 9 | return { 10 | "artists" : state.search.artists, 11 | "albums" : state.search.albums, 12 | "songs" : searchSongsSelector(state) 13 | } 14 | } 15 | 16 | const mapDispatchToProps = { search } 17 | 18 | 19 | export default connect( 20 | mapStateToProps, 21 | mapDispatchToProps 22 | )(SearchView) -------------------------------------------------------------------------------- /src/components/SettingsView/SettingsView.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | // UI 4 | import ThemePicker from '../ThemePicker' 5 | import SidebarSettings from '../SidebarSettings' 6 | import ScrobbleSetting from '../ScrobbleSetting' 7 | import { Button, Row, Col, Divider } from 'rsuite' 8 | // Utils 9 | import * as utils from "../../utils/theming" 10 | 11 | export default class SettingsView extends React.Component { 12 | 13 | constructor(props){ 14 | super(props) 15 | this.themes = utils.getAvailableThemes() 16 | } 17 | 18 | onLogOut = () => { 19 | this.props.logout() 20 | } 21 | 22 | render() { 23 | const themes = this.themes 24 | return ( 25 |
26 |

Settings

27 | {/*Scrobble*/} 28 | 29 | {/* Theme picker */} 30 |

Theme Picker

31 | 32 | 33 | 34 | {/* Items to display in the sidebar */} 35 |

Sidebar settings

36 | 37 | 38 | 39 | {/*Log out*/} 40 |
41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 |
52 | ) 53 | } 54 | 55 | } 56 | 57 | SettingsView.propTypes = { 58 | logout : PropTypes.func.isRequired 59 | } 60 | -------------------------------------------------------------------------------- /src/components/SettingsView/SettingsView.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import SettingsView from "./SettingsView" 4 | 5 | describe("", () => { 6 | 7 | it("renders without crashing", () => { 8 | shallow( null} /> ) 9 | }) 10 | 11 | it("should let me log out", () => { 12 | const logout = jest.fn() 13 | const wrapper = shallow( ) 14 | wrapper.find("#logoutButton").simulate("click") 15 | expect(logout).toHaveBeenCalledTimes(1) 16 | }) 17 | 18 | }) -------------------------------------------------------------------------------- /src/components/SettingsView/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { logout } from "../../redux/actions/authActions" 4 | // UI 5 | import SettingsView from './SettingsView' 6 | 7 | const mapDispatchToProps = { logout } 8 | 9 | export default connect( 10 | null, 11 | mapDispatchToProps 12 | )(SettingsView) -------------------------------------------------------------------------------- /src/components/Sidebar/Sidebar.less: -------------------------------------------------------------------------------- 1 | .subsidebar { 2 | height: 100%; 3 | padding: 10px; 4 | display: flex; 5 | flex-direction: column; 6 | } 7 | 8 | .subsidebar .playlists-container { 9 | flex-grow: 1; 10 | overflow: auto; 11 | } 12 | 13 | .subsidebar .section-header { 14 | margin-top: 12px; 15 | margin-bottom: 12px; 16 | } 17 | 18 | .subsidebar .footer-divider { 19 | margin: 15px 0 !important; 20 | } -------------------------------------------------------------------------------- /src/components/Sidebar/Sidebar.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import Sidebar from "./Sidebar" 4 | 5 | describe("", () => { 6 | const playlists = { 7 | "p1" : { 8 | name : "name", 9 | songCount : 10 10 | }, 11 | "p2" : { 12 | name : "name 2", 13 | songCount : 14 14 | } 15 | } 16 | 17 | it("renders without crashing", () => { 18 | shallow( ) 19 | }) 20 | 21 | it("should render a list of playlists", () => { 22 | const wrapper = shallow( ) 23 | // Get playlists visible 24 | expect(wrapper.find("[data-pid='p1']")).toHaveLength(1) 25 | expect(wrapper.find("[data-pid='p2']")).toHaveLength(1) 26 | }) 27 | 28 | it("should let me create new playlists", () => { 29 | const onCreatePlaylistTrigger = jest.fn() 30 | const wrapper = shallow( ) 31 | // Try to create playlist 32 | wrapper.find("#createPlaylistButton").simulate('click') 33 | expect(onCreatePlaylistTrigger).toHaveBeenCalledTimes(1) 34 | }) 35 | 36 | }) 37 | -------------------------------------------------------------------------------- /src/components/Sidebar/index.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | import { Location } from "@reach/router" 3 | // Redux 4 | import { connect } from "react-redux" 5 | // UI 6 | import Sidebar from './Sidebar' 7 | 8 | /* Create a Sidebar connected to Reach's location provider */ 9 | function HOCSidebar(props) { 10 | const sidebarProps = props 11 | return ( 12 | 13 | { 14 | props => { 15 | // Get the location from reach's to highlight the active item 16 | const currentPath = props.location.pathname 17 | return 18 | } 19 | } 20 | 21 | ) 22 | } 23 | 24 | 25 | const mapStateToProps = (state) => { 26 | return { 27 | "playlists" : state.playlists.byId, 28 | } 29 | } 30 | 31 | export default connect( 32 | mapStateToProps, 33 | null 34 | )(HOCSidebar) -------------------------------------------------------------------------------- /src/components/SidebarSettings/SidebarSettings.js: -------------------------------------------------------------------------------- 1 | import React, {useEffect, useState} from "react" 2 | import { CheckboxGroup, Checkbox } from 'rsuite' 3 | // settings 4 | import * as settings from "../../utils/settings" 5 | 6 | export default function SidebarSettings(props) { 7 | const allOptions = settings.POSSIBLE_SIDEBAR_LINKS 8 | const [value, setValue] = useState([]) 9 | 10 | // load from settings 11 | useEffect(() => { 12 | setValue(settings.getSidebarDisplaySettings().map(s => s.key)) 13 | }, []) 14 | 15 | // update settings 16 | function update_settings(newValue) { 17 | setValue(newValue) 18 | settings.setSidebarDisplaySettings(allOptions.filter(o => newValue.includes(o.key))) 19 | } 20 | 21 | return ( 22 |
23 | 24 |

Select the items to display in the sidebar (NOTE: you need to refresh the site for these changes to take effect)

25 | { 26 | allOptions.map(option => ( 27 | 28 | {option.text} 29 | 30 | )) 31 | } 32 |
33 |
34 | ) 35 | } -------------------------------------------------------------------------------- /src/components/SidebarSettings/SidebarSettings.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import SidebarSettings from "./SidebarSettings" 4 | 5 | describe("", () => { 6 | 7 | it("renders without crashing", () => { 8 | shallow( ) 9 | }) 10 | 11 | it("should change the settings of the sidebar", () => { 12 | const loadSongsOfGenre = jest.fn() 13 | const wrapper = shallow( ) 14 | // change the values and expect no crash 15 | wrapper.find("[name='genresCheckboxList']").simulate("change", []) 16 | }) 17 | 18 | }) -------------------------------------------------------------------------------- /src/components/SidebarSettings/index.js: -------------------------------------------------------------------------------- 1 | // UI 2 | import SidebarSettings from './SidebarSettings' 3 | 4 | export default SidebarSettings -------------------------------------------------------------------------------- /src/components/SongsTable/SongsTable.less: -------------------------------------------------------------------------------- 1 | i.icon-when-playing { 2 | display: none; 3 | } 4 | 5 | .currently-playing i.icon-when-playing { 6 | display: initial; 7 | } -------------------------------------------------------------------------------- /src/components/SongsTable/SongsTable.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import SongsTable from "./SongsTable" 4 | 5 | describe("", () => { 6 | const songs = [...Array(500).keys()].map(i => {return { 7 | id : i.toString(), 8 | title: `song ${i}`, 9 | artist : `artist ${Math.random().toString(36).substring(7)}`, 10 | album : `album ${i}`, 11 | starred: i%2 == 0 12 | }}) 13 | 14 | it("renders without crashing", () => { 15 | shallow( ) 16 | }) 17 | 18 | it("renders a list of songs without crashing", () => { 19 | const wrapper = shallow( ) 20 | }) 21 | 22 | it("should start playing the list of songs when an item is clicked", () => { 23 | const putSongsInQueue = jest.fn() 24 | const wrapper = shallow( ) 25 | wrapper.find("#songsTable").simulate("rowClick", songs[0]) 26 | expect(putSongsInQueue).toHaveBeenCalledTimes(1) 27 | }) 28 | 29 | it("should let me select a single song", () => { 30 | const onSongsSelected = jest.fn() 31 | const wrapper = shallow( ) 32 | wrapper.find("#checkColumn").simulate("change", songs[0]) 33 | expect(onSongsSelected).toHaveBeenCalledTimes(1) 34 | }) 35 | 36 | it("should let me select all songs", () => { 37 | const onSongsSelected = jest.fn() 38 | const wrapper = shallow( ) 39 | wrapper.find("#checkAllCell").simulate("change", songs[0]) 40 | expect(onSongsSelected).toHaveBeenCalledTimes(1) 41 | }) 42 | 43 | it("should let me change the sorting of the songs", () => { 44 | const onSongsSorted = jest.fn() 45 | const wrapper = shallow( ) 46 | wrapper.find("#songsTable").simulate("sortColumn", "artist", "asc") 47 | // TODO: check sorting. How to deal with the async call without explicitly calling the method? 48 | }) 49 | 50 | it("should let me apply a filter on the songs", () => { 51 | const wrapper = shallow( ) 52 | wrapper.setProps({songsFilter: "5"}) 53 | // TODO: check filtering. How to deal with the async call without explicitly calling the method? 54 | }) 55 | 56 | it("should let me overwrite the default click action", () => { 57 | const onSongClicked = jest.fn() 58 | const wrapper = shallow( ) 59 | wrapper.find("#songsTable").simulate("rowClick", songs[0]) 60 | expect(onSongClicked).toHaveBeenCalledTimes(1) 61 | }) 62 | 63 | }) -------------------------------------------------------------------------------- /src/components/SongsTable/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { putSongsInQueue } from "../../redux/actions/songsActions" 4 | import { getSongCurrentlyPlayingSelector } from '../../redux/selectors/musicPlayerSelector' 5 | // UI 6 | import SongsTable from './SongsTable' 7 | 8 | const mapStateToProps = (state) => { 9 | return { 10 | currentSongPlaying : getSongCurrentlyPlayingSelector(state) 11 | } 12 | } 13 | 14 | const mapDispatchToProps = { putSongsInQueue } 15 | 16 | export default connect( 17 | mapStateToProps, 18 | mapDispatchToProps 19 | )(SongsTable) -------------------------------------------------------------------------------- /src/components/SongsTableEnhanced/index.js: -------------------------------------------------------------------------------- 1 | // Redux 2 | import { connect } from "react-redux" 3 | import { addSongsToPlaylist } from "../../redux/actions/playlistsActions" 4 | import { addSongsToQueue, putSongsInQueue } from "../../redux/actions/songsActions" 5 | import { setStarOnSongs } from "../../redux/actions/favouritesActions" 6 | // UI 7 | import SongsTableEnhanced from './SongsTableEnhanced' 8 | 9 | const mapDispatchToProps = { addSongsToQueue, addSongsToPlaylist, setStarOnSongs, playAllSongs: putSongsInQueue } 10 | 11 | export default connect( 12 | null, 13 | mapDispatchToProps 14 | )(SongsTableEnhanced) 15 | -------------------------------------------------------------------------------- /src/components/ThemePicker/ThemePicker.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | // UI 4 | import { Avatar, Col } from 'rsuite' 5 | import "./ThemePicker.less" 6 | // Utils 7 | import * as utils from "../../utils/theming" 8 | 9 | export default class ThemePicker extends React.Component { 10 | 11 | render() { 12 | const themes = this.props.themes || {} 13 | return ( 14 | <> 15 | { 16 | Object.keys(themes).map( name => ( 17 | {utils.changeTheme(name)} }> 18 | 19 | {utils.formatName(name)} 20 | 21 | )) 22 | } 23 | 24 | ) 25 | } 26 | 27 | } 28 | 29 | ThemePicker.propTypes = { 30 | themes : PropTypes.object 31 | } -------------------------------------------------------------------------------- /src/components/ThemePicker/ThemePicker.less: -------------------------------------------------------------------------------- 1 | .theme-element { 2 | padding : 15px 20px; 3 | transition: color .3s linear, background-color .3s linear; 4 | transition-property: color, background-color; 5 | transition-duration: 0.3s, 0.3s; 6 | transition-timing-function: linear, linear; 7 | transition-delay: 0s, 0s; 8 | } 9 | 10 | .theme-element .theme-element-color-light { 11 | border: 3px solid #fff; 12 | } 13 | 14 | .theme-element .theme-element-color-dark { 15 | border: 3px solid #0f131a; 16 | } -------------------------------------------------------------------------------- /src/components/ThemePicker/ThemePicker.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { shallow } from 'enzyme' 3 | import ThemePicker from "./ThemePicker" 4 | 5 | describe("", () => { 6 | 7 | it("renders without crashing", () => { 8 | shallow( ) 9 | }) 10 | 11 | it("should show a list of themes", () => { 12 | const themes = { "lightOrange" : {"base-color" : "#fff"}, "darkBlue" : {}, "black" : {} } 13 | const wrapper = shallow( ) 14 | // Look for themes 15 | expect(wrapper.find("[data-theme-name='lightOrange']").text()).toBe("Light orange") 16 | expect(wrapper.find("[data-theme-name='darkBlue']").text()).toBe("Dark blue") 17 | expect(wrapper.find("[data-theme-name='black']").text()).toBe("black") 18 | }) 19 | 20 | }) -------------------------------------------------------------------------------- /src/components/ThemePicker/index.js: -------------------------------------------------------------------------------- 1 | // UI 2 | import ThemePicker from './ThemePicker' 3 | 4 | export default ThemePicker -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import * as serviceWorker from './serviceWorker' 4 | // Main component 5 | import Main from "./Main.js" 6 | // Theming 7 | import * as theming from "./utils/theming" 8 | 9 | // Init app 10 | theming.initTheme() 11 | ReactDOM.render( 12 |
, 13 | document.getElementById('root') 14 | ) 15 | 16 | // If you want your app to work offline and load faster, you can change 17 | // unregister() to register() below. Note this comes with some pitfalls. 18 | // Learn more about service workers: https://bit.ly/CRA-PWA 19 | serviceWorker.unregister() -------------------------------------------------------------------------------- /src/redux/actions/_tests_/apiStatusActions.test.js: -------------------------------------------------------------------------------- 1 | // Own imports to test 2 | import * as actions from '../apiStatusActions' 3 | import * as types from '../actionTypes' 4 | import * as alerts from '../alertsActions' 5 | 6 | describe('async tasks actions', () => { 7 | 8 | it('creates BEGIN_ASYNC_OPERATION when an async task is started', () => { 9 | expect(actions.beginAsyncTask()).toEqual({ type: types.BEGIN_ASYNC_OPERATION }) 10 | }) 11 | 12 | it('creates END_ASYNC_OPERATION when an async task is finished successfully', () => { 13 | expect(actions.asyncTaskSuccess()).toEqual({ type: types.END_ASYNC_OPERATION }) 14 | }) 15 | 16 | it('creates END_ASYNC_OPERATION with an alert to show when an async task is finished successfully with a message', () => { 17 | const message = "message" 18 | expect(actions.asyncTaskSuccess(message)).toEqual({ type: types.END_ASYNC_OPERATION, ...alerts.alertSuccessObject(message) }) 19 | }) 20 | 21 | it('creates END_ASYNC_OPERATION when an async task is finished with a warning', () => { 22 | expect(actions.asyncTaskWarning()).toEqual({ type: types.END_ASYNC_OPERATION }) 23 | }) 24 | 25 | it('creates END_ASYNC_OPERATION with an alert to show when an async task is finished with a warning and a message', () => { 26 | const message = "message" 27 | expect(actions.asyncTaskWarning(message)).toEqual({ type: types.END_ASYNC_OPERATION, ...alerts.alertWarningObject(message) }) 28 | }) 29 | 30 | it('creates END_ASYNC_OPERATION when an async task is finished with an error', () => { 31 | expect(actions.asyncTaskError()).toEqual({ type: types.END_ASYNC_OPERATION }) 32 | }) 33 | 34 | it('creates END_ASYNC_OPERATION with an alert to show when an async task is finished with an error and a message', () => { 35 | const message = "message" 36 | expect(actions.asyncTaskError(message)).toEqual({ type: types.END_ASYNC_OPERATION, ...alerts.alertErrorObject(message) }) 37 | }) 38 | 39 | }) -------------------------------------------------------------------------------- /src/redux/actions/_tests_/genresActions.test.js: -------------------------------------------------------------------------------- 1 | // Mocking redux 2 | import configureMockStore from 'redux-mock-store' 3 | import thunk from 'redux-thunk' 4 | import fetchMock from 'fetch-mock' 5 | // Own imports to test 6 | import * as actions from '../genresActions' 7 | import * as types from '../actionTypes' 8 | import * as alerts from "../alertsActions" 9 | 10 | const middlewares = [thunk] 11 | const mockStore = configureMockStore(middlewares) 12 | 13 | describe('genres actions', () => { 14 | 15 | afterEach(() => { 16 | fetchMock.restore() 17 | }) 18 | 19 | it('creates LOAD_SONGS_OF_GENRE_SUCCESS when fetching all songs of one genre', () => { 20 | // Fetch all artists 21 | fetchMock.get('*', { 22 | body: {"subsonic-response": { "status" : "ok", "songsByGenre" : { "song" : [1,2,3] } }, "status": "ok","version": "1.9.0"}, 23 | headers: { 'content-type': 'application/json' } 24 | }) 25 | 26 | const expectedActions = [ 27 | { type: types.BEGIN_ASYNC_OPERATION }, 28 | { type: types.LOAD_SONGS_OF_GENRE_SUCCESS, payload : { songs : [1,2,3,1,2,3] } }, 29 | { type: types.END_ASYNC_OPERATION }, 30 | ] 31 | 32 | const store = mockStore({}) 33 | return store.dispatch(actions.loadSongsOfGenre({value:"blues", songCount:600})).then(() => { 34 | // return of async actions 35 | const actions = store.getActions() 36 | expect(actions).toEqual(expectedActions) 37 | }) 38 | }) 39 | 40 | it('Create an error message when failed to fetch songs of a genre', () => { 41 | fetchMock.get('*', 500) 42 | 43 | const expectedActions = [ 44 | { type: types.BEGIN_ASYNC_OPERATION }, 45 | { type: types.END_ASYNC_OPERATION, alert : { type : alerts.ALERT_TYPE_ERROR } } 46 | ] 47 | 48 | const store = mockStore({}) 49 | return store.dispatch(actions.loadSongsOfGenre({value:"rock", songCount:200})).then(() => { 50 | // return of async actions 51 | const actions = store.getActions() 52 | expect(actions).toMatchObject(expectedActions) 53 | }) 54 | }) 55 | 56 | 57 | }) -------------------------------------------------------------------------------- /src/redux/actions/_tests_/searchActions.test.js: -------------------------------------------------------------------------------- 1 | // Mocking redux 2 | import configureMockStore from 'redux-mock-store' 3 | import thunk from 'redux-thunk' 4 | import fetchMock from 'fetch-mock' 5 | // Own imports to test 6 | import * as alerts from "../alertsActions" 7 | import * as actions from '../searchActions' 8 | import * as types from '../actionTypes' 9 | 10 | const middlewares = [thunk] 11 | const mockStore = configureMockStore(middlewares) 12 | 13 | describe('search actions', () => { 14 | 15 | afterEach(() => { 16 | fetchMock.restore() 17 | }) 18 | 19 | it('creates a SEARCH_RESULT when successfully performing a search', () => { 20 | fetchMock.getOnce('*', { 21 | body: {"subsonic-response": { "status" : "ok", "searchResult3" : {} }, "status": "ok","version": "1.9.0"}, 22 | headers: { 'content-type': 'application/json' } 23 | }) 24 | 25 | const expectedActions = [ 26 | { type: types.BEGIN_ASYNC_OPERATION }, 27 | { type: types.SEARCH_RESULT, payload : {} }, 28 | { type: types.END_ASYNC_OPERATION } 29 | ] 30 | 31 | const store = mockStore({}) 32 | return store.dispatch(actions.search("query")).then(() => { 33 | // return of async actions 34 | expect(store.getActions()).toMatchObject(expectedActions) 35 | }) 36 | }) 37 | 38 | it('creates an ERROR alert when failing to search', () => { 39 | fetchMock.getOnce('*', 500) 40 | 41 | const expectedActions = [ 42 | { type: types.BEGIN_ASYNC_OPERATION }, 43 | { type: types.END_ASYNC_OPERATION, alert : { type : alerts.ALERT_TYPE_ERROR } } 44 | ] 45 | 46 | const store = mockStore({}) 47 | return store.dispatch(actions.search("query")).then(() => { 48 | // return of async actions 49 | expect(store.getActions()).toMatchObject(expectedActions) 50 | }) 51 | }) 52 | 53 | }) -------------------------------------------------------------------------------- /src/redux/actions/_tests_/songsActions.test.js: -------------------------------------------------------------------------------- 1 | // Own imports to test 2 | import * as actions from '../songsActions' 3 | import * as types from '../actionTypes' 4 | import * as alerts from "../alertsActions" 5 | 6 | describe('songs actions', () => { 7 | 8 | it('creates an PUT_SONGS_IN_QUEUE when wanting to put new songs in the queue', () => { 9 | const songs = [{id : '1'}] 10 | expect(actions.putSongsInQueue(songs)).toMatchObject({ type: types.PUT_SONGS_IN_QUEUE, payload : { songs } }) 11 | }) 12 | 13 | it('creates a PLAY_NEXT_SONG when wanting to play the next song in the queue', () => { 14 | expect(actions.playNextSong()).toMatchObject({ type: types.PLAY_NEXT_SONG }) 15 | }) 16 | 17 | it('creates a PLAY_PREVIOUS_SONG when wanting to play the previous song in the queue', () => { 18 | expect(actions.playPreviousSong()).toMatchObject({ type: types.PLAY_PREVIOUS_SONG }) 19 | }) 20 | 21 | it('creates an ADD_SONGS_TO_QUEUE when wanting to add new songs to the current queue with a message to display', () => { 22 | const songs = [{id : '1'}] 23 | expect(actions.addSongsToQueue(songs)).toMatchObject({ type: types.ADD_SONGS_TO_QUEUE, ...alerts.alertSuccessObject("1 songs added to the queue!"), payload : { songs } }) 24 | }) 25 | 26 | it('creates a TOGGLE_SHUFFLE_ON when shuffle is turned on', () => { 27 | expect(actions.toggleShuffle(true)).toMatchObject({ type: types.TOGGLE_SHUFFLE_ON }) 28 | }) 29 | 30 | it('creates a TOGGLE_SHUFFLE_OFF when shuffle is turned off', () => { 31 | expect(actions.toggleShuffle(false)).toMatchObject({ type: types.TOGGLE_SHUFFLE_OFF }) 32 | }) 33 | 34 | }) -------------------------------------------------------------------------------- /src/redux/actions/actionTypes.js: -------------------------------------------------------------------------------- 1 | // Load list of artists 2 | export const LOAD_ARTISTS = "LOAD_ARTISTS" 3 | export const LOAD_ARTISTS_INDEX_SUCCESS = "LOAD_ARTISTS_INDEX_SUCCESS" 4 | // Load single artist 5 | export const LOAD_ONE_ARTIST_SUCCESS = "LOAD_ONE_ARTIST_SUCCESS" 6 | export const LOAD_SONGS_OF_ONE_ARTIST_SUCCESS = "LOAD_SONGS_OF_ONE_ARTIST_SUCCESS" 7 | // Load playlists 8 | export const LOAD_PLAYLISTS = "LOAD_PLAYLISTS" 9 | export const LOAD_PLAYLISTS_SUCCESS = "LOAD_PLAYLISTS_SUCCESS" 10 | export const LOAD_SINGLE_PLAYLIST_SUCCESS = "LOAD_SINGLE_PLAYLIST_SUCCESS" 11 | // Edit playlist 12 | export const ADD_SONGS_TO_PLAYLIST_RESULT = "ADD_SONGS_TO_PLAYLIST_RESULT" 13 | export const REMOVE_SONGS_FROM_PLAYLIST_RESULT = "REMOVE_SONGS_FROM_PLAYLIST_RESULT" 14 | export const DELETE_PLAYLIST_RESULT = "DELETE_PLAYLIST_RESULT" 15 | export const EDIT_PLAYLIST_RESULT = "EDIT_PLAYLIST_RESULT" 16 | // Songs management 17 | export const ADD_SONGS_TO_QUEUE = "ADD_SONGS_TO_QUEUE" 18 | export const PUT_SONGS_IN_QUEUE = "PUT_SONGS_IN_QUEUE" 19 | export const PLAY_NEXT_SONG = "PLAY_NEXT_SONG" 20 | export const PLAY_PREVIOUS_SONG = "PLAY_PREVIOUS_SONG" 21 | export const STAR_SONG_RESULT = "STAR_SONG_RESULT" 22 | export const CLEAR_QUEUE = "CLEAR_QUEUE" 23 | export const REMOVE_SONGS_FROM_QUEUE = "REMOVE_SONGS_FROM_QUEUE" 24 | export const SEEK_TO_SONG_IN_QUEUE = "SEEK_TO_SONG_IN_QUEUE" 25 | export const TOGGLE_SHUFFLE_ON = "TOGGLE_SHUFFLE_ON" 26 | export const TOGGLE_SHUFFLE_OFF = "TOGGLE_SHUFFLE_OFF" 27 | // Authentication 28 | export const LAZY_LOGIN_IGNORE = "LAZY_LOGIN_IGNORE" 29 | export const LOGIN_USER_REQUEST = "LOGIN_USER_REQUEST" 30 | export const LOGIN_USER_FAILURE = "LOGIN_USER_FAILURE" 31 | export const LOGIN_USER_SUCCESS = "LOGIN_USER_SUCCESS" 32 | export const LOGOUT_USER = "LOGOUT_USER" 33 | // API calls 34 | export const BEGIN_ASYNC_OPERATION = "BEGIN_ASYNC_OPERATION" 35 | export const END_ASYNC_OPERATION = "END_ASYNC_OPERATION" 36 | // Search 37 | export const SEARCH_RESULT = "SEARCH_RESULT" 38 | // Albums 39 | export const LOAD_ALBUM_SUCCESS = "LOAD_ONE_ALBUM_SUCCESS" 40 | export const LOAD_ALBUMS_LIST_SUCCESS = "LOAD_ALBUMS_LIST_SUCCESS" 41 | export const STAR_ALBUM_RESULT = "STAR_ALBUM_RESULT" 42 | // Favourites 43 | export const LOAD_FAVOURITES_RESULT = "LOAD_FAVOURITES_RESULT" 44 | // Genres 45 | export const LOAD_SONGS_OF_GENRE_SUCCESS = "LOAD_SONGS_OF_GENRE_SUCCESS" -------------------------------------------------------------------------------- /src/redux/actions/alertsActions.js: -------------------------------------------------------------------------------- 1 | 2 | export const ALERT_TYPE_SUCCESS = "ALERT_TYPE_SUCCESS" 3 | export const ALERT_TYPE_WARNING = "ALERT_TYPE_WARNING" 4 | export const ALERT_TYPE_ERROR = "ALERT_TYPE_ERROR" 5 | 6 | /* Functions NOT meant to dispatch actions, only to build the alerts */ 7 | export function alertSuccessObject(message) { 8 | return { alert: { type: ALERT_TYPE_SUCCESS, message: message } } 9 | } 10 | 11 | export function alertWarningObject(message) { 12 | return { alert: { type: ALERT_TYPE_WARNING, message : message } } 13 | } 14 | 15 | export function alertErrorObject(message) { 16 | return { alert: { type: ALERT_TYPE_ERROR, message : message } } 17 | } 18 | -------------------------------------------------------------------------------- /src/redux/actions/apiStatusActions.js: -------------------------------------------------------------------------------- 1 | import * as types from "./actionTypes" 2 | import * as alerts from "./alertsActions" 3 | 4 | export function beginAsyncTask() { 5 | return { type: types.BEGIN_ASYNC_OPERATION } 6 | } 7 | 8 | export function asyncTaskSuccess(message) { 9 | return message ? { type: types.END_ASYNC_OPERATION, ...alerts.alertSuccessObject(message) } : { type: types.END_ASYNC_OPERATION } 10 | } 11 | 12 | export function asyncTaskWarning(message) { 13 | return message ? { type: types.END_ASYNC_OPERATION, ...alerts.alertWarningObject(message) } : { type: types.END_ASYNC_OPERATION } 14 | } 15 | 16 | export function asyncTaskError(message) { 17 | return message ? { type: types.END_ASYNC_OPERATION, ...alerts.alertErrorObject(message) } : { type: types.END_ASYNC_OPERATION } 18 | } 19 | -------------------------------------------------------------------------------- /src/redux/actions/artistsActions.js: -------------------------------------------------------------------------------- 1 | import * as types from "./actionTypes" 2 | import subsonic from "../../api/subsonicApi" 3 | import { beginAsyncTask, asyncTaskSuccess, asyncTaskError } from "./apiStatusActions" 4 | 5 | /* Load multiple artists */ 6 | export function loadArtistsSuccess(artists) { 7 | return { type: types.LOAD_ARTISTS_INDEX_SUCCESS, payload: {artistsIndex: artists} } 8 | } 9 | 10 | export function loadArtists() { 11 | return async (dispatch) => { 12 | dispatch(beginAsyncTask()) 13 | try { 14 | const artists = await subsonic.getArtists() 15 | dispatch(loadArtistsSuccess(artists)) 16 | dispatch(asyncTaskSuccess()) 17 | } 18 | catch(error) { 19 | console.error(error) 20 | dispatch(asyncTaskError("Could not load artists")) 21 | } 22 | } 23 | } 24 | 25 | /* Load songs of just one artist */ 26 | export const loadSongsOfArtistSuccess = songs => ({type : types.LOAD_SONGS_OF_ONE_ARTIST_SUCCESS, payload: { songs : songs} }) 27 | 28 | async function loadSongsOfArtist(artist) { 29 | // Create a "big" promise to fetch all albums and return 30 | // just one result with all the content 31 | return Promise.all( artist.album.map(album => subsonic.getAlbum(album.id)) ) 32 | .then(response => { 33 | // Combine all the songs in one single result 34 | return response.reduce( (accum, current) => accum.concat(current.song), [] ) 35 | }) 36 | } 37 | 38 | /* Load information of one artist along with its songs */ 39 | 40 | export const loadOneArtistSuccess = artist => ({type: types.LOAD_ONE_ARTIST_SUCCESS, payload : {artist: artist}}) 41 | 42 | export function loadOneArtist(artistId) { 43 | return async (dispatch) => { 44 | dispatch(beginAsyncTask()) 45 | try { 46 | const artist = await subsonic.getArtist(artistId) 47 | dispatch(loadOneArtistSuccess(artist)) 48 | dispatch(asyncTaskSuccess()) 49 | // Now load all its songs 50 | dispatch(beginAsyncTask()) 51 | const songs = await loadSongsOfArtist(artist) 52 | dispatch(loadSongsOfArtistSuccess(songs)) 53 | dispatch(asyncTaskSuccess()) 54 | } 55 | catch(error) { 56 | console.error(error) 57 | dispatch(asyncTaskError("Unable to load artist")) 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /src/redux/actions/authActions.js: -------------------------------------------------------------------------------- 1 | import * as types from "./actionTypes" 2 | import subsonic from "../../api/subsonicApi" 3 | 4 | export function loginUserRequest() { 5 | return { type: types.LOGIN_USER_REQUEST } 6 | } 7 | 8 | export function loginUserSuccess(host, username, enc) { 9 | localStorage.setItem('host', host) 10 | localStorage.setItem('username', username) 11 | localStorage.setItem('enc', enc) 12 | // Update subsonic API 13 | subsonic.setConfig(host, username, enc, false) 14 | // Log in 15 | return { 16 | type: types.LOGIN_USER_SUCCESS, 17 | } 18 | } 19 | 20 | export function loginUserFailure(error) { 21 | localStorage.removeItem('host') 22 | localStorage.removeItem('username') 23 | localStorage.removeItem('enc') 24 | return { 25 | type: types.LOGIN_USER_FAILURE, 26 | payload: { 27 | statusText: error.message 28 | } 29 | } 30 | } 31 | 32 | export function loginUser(host, username, password, encodePassword = true) { 33 | return async (dispatch) => { 34 | dispatch(loginUserRequest()) 35 | // Sanitize host removing trialing / 36 | host = host.replace(/\/$/, '') 37 | // Perform login 38 | try { 39 | const success = await subsonic.login(host, username, password, encodePassword) 40 | if( success ) { 41 | const passwordToStore = encodePassword ? subsonic.getEncodedPassword(password) : password 42 | dispatch(loginUserSuccess(host, username, passwordToStore)) 43 | } 44 | else { 45 | dispatch(loginUserFailure({statusText:"Authentication failed"})) 46 | } 47 | } 48 | catch(err) { 49 | console.error(err) 50 | dispatch(loginUserFailure(err)) 51 | } 52 | 53 | } 54 | } 55 | 56 | export function lazyLoginUser() { 57 | // Check if we have credentials stored and try to log in existing user 58 | const host = localStorage.getItem('host') 59 | const username = localStorage.getItem('username') 60 | const enc = localStorage.getItem('enc') 61 | if( host && username && enc ) { 62 | return loginUser(host, username, enc, false) 63 | } 64 | return {type: types.LAZY_LOGIN_IGNORE} 65 | } 66 | 67 | export function logout() { 68 | localStorage.clear() 69 | return { type: types.LOGOUT_USER } 70 | } -------------------------------------------------------------------------------- /src/redux/actions/favouritesActions.js: -------------------------------------------------------------------------------- 1 | import * as types from "./actionTypes" 2 | import subsonic from "../../api/subsonicApi" 3 | import { beginAsyncTask, asyncTaskSuccess, asyncTaskError, asyncTaskWarning } from "./apiStatusActions" 4 | 5 | export function favouriteSongsLoaded(favSongs) { 6 | return {type: types.LOAD_FAVOURITES_RESULT, payload: {songs : favSongs} } 7 | } 8 | 9 | export function loadFavouriteSongs() { 10 | return async (dispatch) => { 11 | dispatch(beginAsyncTask()) 12 | try { 13 | const favourites = await subsonic.getStarred() 14 | const favSongs = favourites["song"] || [] 15 | // Set to state 16 | dispatch(favouriteSongsLoaded(favSongs)) 17 | dispatch(asyncTaskSuccess()) 18 | } 19 | catch(error) { 20 | console.error(error) 21 | dispatch(asyncTaskError("Could not retrieve favourites")) 22 | } 23 | } 24 | } 25 | 26 | export function starredSongModified(songIds, setStarred) { 27 | return { type: types.STAR_SONG_RESULT, payload : { songIds: songIds, starred : setStarred } } 28 | } 29 | 30 | export function setStarOnSongs(songs, setStarred) { 31 | return async (dispatch) => { 32 | dispatch(beginAsyncTask()) 33 | // We need to remove the songs that are already starred before calling the API. 34 | // As song.starred is a string with the date the song was starred, 35 | // we need to "toggle" it to convert to a boolean 36 | const songIds = songs.filter(song => !song.starred === setStarred).map(song => song.id) 37 | if( songIds.length === 0 ) { 38 | dispatch(asyncTaskWarning("All songs are already in favourites")) 39 | } 40 | else { 41 | try { 42 | let result = null 43 | let message = null 44 | if( setStarred ) { 45 | result = await subsonic.star(songIds) 46 | message = result ? "Songs added to favs!" : "Unable to add songs" 47 | setStarred = new Date().toISOString() 48 | } 49 | else { 50 | result = await subsonic.unstar(songIds) 51 | message = result ? "Songs removed from favs!" : "Unable to remove songs" 52 | } 53 | // Dispatch result 54 | if( result ) { 55 | dispatch(starredSongModified(songIds, setStarred)) 56 | dispatch(asyncTaskSuccess(message)) 57 | } 58 | else { 59 | dispatch(asyncTaskError(message)) 60 | } 61 | } 62 | catch(error) { 63 | console.error(error) 64 | dispatch(asyncTaskError(`Could not change ${songIds.length} songs`)) 65 | } 66 | } 67 | 68 | } 69 | } -------------------------------------------------------------------------------- /src/redux/actions/genresActions.js: -------------------------------------------------------------------------------- 1 | import * as types from "./actionTypes" 2 | import subsonic from "../../api/subsonicApi" 3 | import { beginAsyncTask, asyncTaskSuccess, asyncTaskError } from "./apiStatusActions" 4 | 5 | async function performGenreRequests(genre) { 6 | // Load all the songs of this genre in chunks of 500 (max allowed by Subsonic API) 7 | let offsets = [] 8 | let count = 0 9 | while( count < genre.songCount ) { 10 | offsets.push([count]) 11 | count += 500 12 | } 13 | // Create a "big" promise to fetch all the songs and return 14 | // just one result with all the content 15 | return Promise.all( offsets.map(offset => subsonic.getSongsByGenre(genre.value, offset)) ) 16 | .then(response => { 17 | // Combine all the songs in one single result 18 | return response.reduce( (accum, current) => accum.concat(current), [] ) 19 | }) 20 | } 21 | 22 | export const loadSongsOfGenreSuccess = songs => ({type : types.LOAD_SONGS_OF_GENRE_SUCCESS, payload: { songs : songs} }) 23 | 24 | export function loadSongsOfGenre(genre) { 25 | return async (dispatch) => { 26 | dispatch(beginAsyncTask()) 27 | try { 28 | const songs = await performGenreRequests(genre) 29 | dispatch(loadSongsOfGenreSuccess(songs)) 30 | dispatch(asyncTaskSuccess()) 31 | } 32 | catch(error) { 33 | console.error(error) 34 | dispatch(asyncTaskError("Unable to load genre")) 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /src/redux/actions/searchActions.js: -------------------------------------------------------------------------------- 1 | import * as types from "./actionTypes" 2 | import subsonic from "../../api/subsonicApi" 3 | import { beginAsyncTask, asyncTaskSuccess, asyncTaskError } from "./apiStatusActions" 4 | 5 | export function searchAction(result) { 6 | return { type: types.SEARCH_RESULT, payload:result } 7 | } 8 | 9 | export function search(query) { 10 | return async (dispatch) => { 11 | dispatch(beginAsyncTask()) 12 | try { 13 | const result = await subsonic.search(query) 14 | dispatch(searchAction(result)) 15 | dispatch(asyncTaskSuccess()) 16 | } 17 | catch(error) { 18 | console.error(error) 19 | dispatch(asyncTaskError("Unable to perform search")) 20 | } 21 | } 22 | } -------------------------------------------------------------------------------- /src/redux/actions/songsActions.js: -------------------------------------------------------------------------------- 1 | import * as types from "./actionTypes" 2 | import * as alerts from "./alertsActions" 3 | import * as settings from "../../utils/settings" 4 | 5 | export function addSongsToQueue(songs) { 6 | return { type: types.ADD_SONGS_TO_QUEUE, payload: {songs}, ...alerts.alertSuccessObject(`${songs.length} songs added to the queue!`) } 7 | } 8 | 9 | export function putSongsInQueue(songs, songToPlay=null) { 10 | return { type: types.PUT_SONGS_IN_QUEUE, payload: {songs, songToPlay} } 11 | } 12 | 13 | export function seekToSongInQueue(song) { 14 | return { type: types.SEEK_TO_SONG_IN_QUEUE, payload: {song} } 15 | } 16 | 17 | export function playNextSong() { 18 | return { type: types.PLAY_NEXT_SONG } 19 | } 20 | 21 | export function playPreviousSong() { 22 | return { type: types.PLAY_PREVIOUS_SONG } 23 | } 24 | 25 | export function clearQueue() { 26 | return { type: types.CLEAR_QUEUE } 27 | } 28 | 29 | export function removeSongsFromQueue(songs) { 30 | return { type: types.REMOVE_SONGS_FROM_QUEUE, payload: {songs} } 31 | } 32 | 33 | export function toggleShuffle(turnOn) { 34 | settings.setShuffle(turnOn) 35 | return { type: turnOn ? types.TOGGLE_SHUFFLE_ON : types.TOGGLE_SHUFFLE_OFF } 36 | } 37 | -------------------------------------------------------------------------------- /src/redux/configureStore.dev.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware, compose } from "redux" 2 | import rootReducer from "./reducers" 3 | import reduxImmutableStateInvariant from "redux-immutable-state-invariant" 4 | import thunk from "redux-thunk" 5 | 6 | /** 7 | * Logs all actions and states after they are dispatched. 8 | */ 9 | const logger = store => next => action => { 10 | console.group(action.type) 11 | console.info('dispatching', action) 12 | let result = next(action) 13 | console.log('next state', store.getState()) 14 | console.groupEnd() 15 | return result 16 | } 17 | 18 | export default function configureStore(initialState) { 19 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose // add support for Redux dev tools 20 | 21 | return createStore( 22 | rootReducer, 23 | initialState, 24 | composeEnhancers(applyMiddleware(thunk, logger, reduxImmutableStateInvariant())) 25 | ) 26 | } -------------------------------------------------------------------------------- /src/redux/configureStore.js: -------------------------------------------------------------------------------- 1 | // Use CommonJS require below so we can dynamically import during build-time. 2 | if (process.env.NODE_ENV === "production" || process.env.NODE_ENV === "test") { 3 | module.exports = require("./configureStore.prod"); 4 | } 5 | else { 6 | module.exports = require("./configureStore.dev"); 7 | } -------------------------------------------------------------------------------- /src/redux/configureStore.prod.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from "redux" 2 | import rootReducer from "./reducers" 3 | import thunk from "redux-thunk" 4 | 5 | export default function configureStore(initialState) { 6 | return createStore(rootReducer, initialState, applyMiddleware(thunk)) 7 | } 8 | -------------------------------------------------------------------------------- /src/redux/reducers/_tests_/alertsReducer.test.js: -------------------------------------------------------------------------------- 1 | import alertsReducer from "../alertsReducer" 2 | 3 | describe('alerts reducer', () => { 4 | 5 | it('should no nothing when an action does not contain an alert', () => { 6 | // Define initial state 7 | const initialState = { 8 | 'id' : '1', 9 | 'type' : 'ALERT_TYPE', 10 | 'message' : 'message' 11 | } 12 | // Expect it to return when no specific action is triggered 13 | expect( alertsReducer(initialState, {}) ).toEqual(initialState) 14 | }) 15 | 16 | it('should create a new alert when required by the action', () => { 17 | // Define initial state 18 | const initialState = { 19 | 'id' : '1', 20 | 'type' : 'ALERT_TYPE', 21 | 'message' : 'message' 22 | } 23 | const newAlert = { alert : { type : 'NEW_TYPE', message : 'new message' } } 24 | // Expect it to return when no specific action is triggered 25 | expect( alertsReducer(initialState, newAlert) ).toMatchObject(newAlert.alert) 26 | }) 27 | 28 | }) -------------------------------------------------------------------------------- /src/redux/reducers/_tests_/artistsReducer.test.js: -------------------------------------------------------------------------------- 1 | import artistsReducer from "../artistsReducer" 2 | import { logout } from "../../actions/authActions" 3 | import * as actions from "../../actions/artistsActions" 4 | 5 | describe('artists reducer', () => { 6 | 7 | it('should return the initial state', () => { 8 | // Define initial state 9 | const initialState = { byIndex : [], byId : {} } 10 | // Expect it to return when no specific action is triggered 11 | expect( artistsReducer(initialState, {}) ).toEqual(initialState) 12 | }) 13 | 14 | it('should clear the artists on logout', () => { 15 | // Define initial state 16 | const initialState = { 17 | byIndex : [ 18 | { 19 | name : 'A', 20 | artist : [ {id : '1'} ] 21 | } 22 | ], 23 | byId : { 24 | '1' : {id : '1'} 25 | } 26 | } 27 | // Expect it to return no albums when logout is triggered 28 | expect( artistsReducer(initialState, logout()) ).toEqual({ byIndex : [], byId : {} }) 29 | }) 30 | 31 | it('should put all the artists by index loaded', () => { 32 | // Define initial state 33 | const initialState = { byIndex : [], byId : {} } 34 | // Define loaded artists' index 35 | const loadedArtists = [ 36 | { 37 | name : 'A', 38 | artist : [ {id : '1'} ] 39 | } 40 | ] 41 | // Expect it to replace the existing index with the new one 42 | expect( 43 | artistsReducer(initialState, actions.loadArtistsSuccess(loadedArtists)) 44 | ) 45 | .toEqual({ 46 | byIndex : loadedArtists, 47 | byId : initialState.byId 48 | }) 49 | }) 50 | 51 | it('should put one normalized artist when loaded', () => { 52 | // Define initial state 53 | const initialState = { 54 | byIndex : [ 55 | { 56 | name : 'A', 57 | artist : [ {id : '1'} ] 58 | } 59 | ], 60 | byId : { 61 | '1' : {id : '1'} 62 | } 63 | } 64 | // Define loaded artists' index 65 | const newArtist = { 66 | id : '2', 67 | album : [ 68 | { 'id' : 'album1' }, 69 | { 'id' : 'album2' }, 70 | ] 71 | } 72 | const newState = artistsReducer(initialState, actions.loadOneArtistSuccess(newArtist)) 73 | // Expect it to: 74 | // Keep the index as before 75 | expect( newState.byIndex ) .toEqual(initialState.byIndex) 76 | // As only the details of one artist is displayed at a time, we can clear the existing 77 | // artist.byId with the new one 78 | expect( newState.byId ) .toEqual({ 79 | '2' : { 80 | id : '2', 81 | album : ['album1', 'album2'] 82 | } 83 | }) 84 | }) 85 | 86 | }) -------------------------------------------------------------------------------- /src/redux/reducers/_tests_/asyncTasksReducer.test.js: -------------------------------------------------------------------------------- 1 | import asyncTasksReducer from "../asyncTasksReducer" 2 | import * as apiStatusActions from "../../actions/apiStatusActions" 3 | 4 | describe('async tasks reducer', () => { 5 | 6 | it('should return the initial state', () => { 7 | // Define initial state 8 | const initialState = 5 9 | // Expect it to return when no specific action is triggered 10 | expect( asyncTasksReducer(initialState, {}) ).toEqual(initialState) 11 | }) 12 | 13 | it('should count +1 when an async task is started', () => { 14 | // Define initial state and begin an async task 15 | const initialState = 4 16 | expect( asyncTasksReducer(initialState, apiStatusActions.beginAsyncTask() ) ).toEqual(4+1) 17 | }) 18 | 19 | it('should count -1 when an async task ends in success', () => { 20 | // Define initial state and end an async task 21 | const initialState = 4 22 | expect( asyncTasksReducer(initialState, apiStatusActions.asyncTaskSuccess() ) ).toEqual(4-1) 23 | }) 24 | 25 | it('should count -1 when an async task ends in warning', () => { 26 | // Define initial state and end an async task 27 | const initialState = 4 28 | expect( asyncTasksReducer(initialState, apiStatusActions.asyncTaskWarning() ) ).toEqual(4-1) 29 | }) 30 | 31 | it('should count -1 when an async task ends in error', () => { 32 | // Define initial state and end an async task 33 | const initialState = 4 34 | expect( asyncTasksReducer(initialState, apiStatusActions.asyncTaskError() ) ).toEqual(4-1) 35 | }) 36 | 37 | }) -------------------------------------------------------------------------------- /src/redux/reducers/_tests_/authReducer.test.js: -------------------------------------------------------------------------------- 1 | import authReducer from "../authReducer" 2 | import * as actions from "../../actions/authActions" 3 | 4 | describe('async tasks reducer', () => { 5 | 6 | it('should return the initial state', () => { 7 | // Define initial state 8 | const initialState = { isAuthenticated: false, isAuthenticating: true, statusText: null } 9 | // Expect it to return when no specific action is triggered 10 | expect( authReducer(initialState, {}) ).toEqual(initialState) 11 | }) 12 | 13 | it('should set status as authentication in progress', () => { 14 | const initialState = { isAuthenticated: false, isAuthenticating: true, statusText: null } 15 | expect( authReducer(initialState, actions.loginUserRequest() ) ).toEqual({ 16 | isAuthenticated : false, 17 | isAuthenticating : true, 18 | statusText : null 19 | }) 20 | }) 21 | 22 | it('should set status as authentication was successful', () => { 23 | const initialState = { isAuthenticated: false, isAuthenticating: true, statusText: null } 24 | expect( 25 | authReducer(initialState, actions.loginUserSuccess("host", "username", "enc:pass") ) 26 | ).toMatchObject({ 27 | isAuthenticated : true, 28 | isAuthenticating : false, 29 | }) 30 | }) 31 | 32 | it('should set status as authentication was unsuccessful with an error to display', () => { 33 | const initialState = { isAuthenticated: false, isAuthenticating: true, statusText: null } 34 | const newState = authReducer(initialState, actions.loginUserFailure(new Error('ERROR'))) 35 | expect( newState.isAuthenticated ).toEqual(false) 36 | expect( newState.isAuthenticating ).toEqual(false) 37 | expect( newState.statusText ).toEqual('ERROR') 38 | }) 39 | 40 | it('should clear auth details on logout', () => { 41 | const initialState = { isAuthenticated: true, isAuthenticating: true, statusText: null } 42 | const newState = authReducer(initialState, actions.logout() ) 43 | expect( newState.isAuthenticated ).toEqual(false) 44 | }) 45 | 46 | it('should set authenticated as false when lazy login failed', () => { 47 | const initialState = { isAuthenticated: true, isAuthenticating: true, statusText: null } 48 | const newState = authReducer(initialState, actions.lazyLoginUser() ) 49 | expect( newState.isAuthenticating ).toEqual(false) 50 | }) 51 | 52 | }) -------------------------------------------------------------------------------- /src/redux/reducers/_tests_/favouritesReducer.test.js: -------------------------------------------------------------------------------- 1 | import favouritesReducer from "../favouritesReducer" 2 | import * as actions from "../../actions/favouritesActions" 3 | import { logout } from "../../actions/authActions" 4 | 5 | describe('async tasks reducer', () => { 6 | 7 | it('should return the initial state', () => { 8 | // Define initial state 9 | const initialState = [] 10 | // Expect it to return when no specific action is triggered 11 | expect( favouritesReducer(initialState, {}) ).toEqual(initialState) 12 | }) 13 | 14 | it('should clear the favourites on logout', () => { 15 | const initialState = [ '1', '2', '3' ] 16 | expect( favouritesReducer(initialState, logout() ) ).toEqual([]) 17 | }) 18 | 19 | it('should put the favourites when loaded', () => { 20 | const initialState = [ '1', '2', '3' ] 21 | const favSongs = [ { id: '4'}, { id : '5'} ] 22 | expect( favouritesReducer(initialState, actions.favouriteSongsLoaded(favSongs) ) ).toEqual(['4', '5']) 23 | }) 24 | 25 | it('should add the new songs to favourites when starred', () => { 26 | const initialState = [ '1', '2', '3' ] 27 | expect( 28 | favouritesReducer(initialState, actions.starredSongModified(['4', '5'], new Date().toISOString()) ) 29 | ).toEqual([ '1', '2', '3', '4', '5']) 30 | }) 31 | 32 | it('should remove the songs from favourites when unstarred', () => { 33 | const initialState = [ '1', '2', '3' ] 34 | expect( 35 | favouritesReducer(initialState, actions.starredSongModified(['2'], false) ) 36 | ).toEqual([ '1', '3']) 37 | }) 38 | 39 | }) -------------------------------------------------------------------------------- /src/redux/reducers/albumsReducer.js: -------------------------------------------------------------------------------- 1 | import * as types from "../actions/actionTypes" 2 | import initialState from "./initialState" 3 | import {createReducer} from '../../utils/redux.js' 4 | 5 | export default createReducer(initialState.albums, { 6 | [types.LOAD_ALBUM_SUCCESS]: (state, payload) => { 7 | const album = payload.album 8 | album.song = album.song.map(song => song.id) 9 | return { 10 | ...state, 11 | byId : { 12 | ...state.byId, 13 | [album.id] : album 14 | } 15 | } 16 | }, 17 | [types.LOAD_ALBUMS_LIST_SUCCESS]: (state, payload) => { 18 | const albums = payload.albums.reduce( (accum, curr) => ({ 19 | [curr.id] : curr, ...accum 20 | }), {}) 21 | return { 22 | ...state, 23 | byId : albums 24 | } 25 | }, 26 | [types.LOAD_ONE_ARTIST_SUCCESS]: (state, payload) => { 27 | // Normalize the albums of the artist to just contain the IDs 28 | const artists_albums_by_id = {} 29 | payload.artist.album.forEach(album => { 30 | artists_albums_by_id[album.id] = album 31 | }) 32 | return { 33 | ...state, 34 | byId : artists_albums_by_id 35 | } 36 | }, 37 | [types.LOAD_SONGS_OF_ONE_ARTIST_SUCCESS]: (state, payload) => { 38 | // Look for albums with songs present in this payload and 39 | // put its corresponding IDs 40 | let newByIdState = {...state.byId} 41 | payload.songs.forEach(song => { 42 | const thisAlbum = newByIdState[song.albumId] 43 | if( thisAlbum ){ 44 | newByIdState = { 45 | ...newByIdState, 46 | [song.albumId] : { 47 | ...thisAlbum, 48 | song : thisAlbum.song ? thisAlbum.song.concat(song.id) : [song.id] 49 | } 50 | } 51 | } 52 | }) 53 | return { 54 | ...state, 55 | byId : newByIdState 56 | } 57 | }, 58 | [types.STAR_ALBUM_RESULT]: (state, payload) => { 59 | let newAlbumsById = {} 60 | payload.albumIds.forEach(albumId => { 61 | newAlbumsById[albumId] = { 62 | ...state.byId[albumId], 63 | starred: payload.starred 64 | } 65 | }) 66 | return { 67 | ...state, 68 | byId: { 69 | ...state.byId, 70 | ...newAlbumsById, 71 | } 72 | } 73 | }, 74 | [types.LOGOUT_USER]: (state, payload) => initialState.albums 75 | }) -------------------------------------------------------------------------------- /src/redux/reducers/alertsReducer.js: -------------------------------------------------------------------------------- 1 | 2 | import initialState from "./initialState" 3 | 4 | function build_last_operation_result(type, message) { 5 | return { id: Date.now(), type:type, message:message } 6 | } 7 | 8 | function does_action_contain_alert(action) { 9 | return action.alert && action.alert.type && action.alert.message 10 | } 11 | 12 | /* 13 | * Usually, actions are dispatched with three keys: 14 | * type, payload, alert 15 | * Function "createReducer" in utils/redux.js just takes the "payload" object and passes it to the 16 | * body of the reducer to map it to its appropiate function. 17 | * This reducer doesnt use "createReducer" because it doesn't care about the type 18 | * of the action, it just looks for the "alert" key and if it is present, then that means 19 | * the action meant to display an alert notification and it is displayed. 20 | */ 21 | export default (state = initialState.alert, action) => { 22 | // Check if a valid "alert" object is found in this action 23 | return does_action_contain_alert(action) 24 | ? build_last_operation_result(action.alert.type, action.alert.message) 25 | : state 26 | } -------------------------------------------------------------------------------- /src/redux/reducers/artistsReducer.js: -------------------------------------------------------------------------------- 1 | import * as types from "../actions/actionTypes" 2 | import initialState from "./initialState" 3 | import {createReducer} from '../../utils/redux.js' 4 | 5 | export default createReducer(initialState.artists, { 6 | [types.LOAD_ARTISTS_INDEX_SUCCESS]: (state, payload) => { 7 | return { 8 | ...state, 9 | byIndex : payload.artistsIndex 10 | } 11 | }, 12 | [types.LOAD_ONE_ARTIST_SUCCESS]: (state, payload) => { 13 | // Normalize the albums of the artist to just contain the IDs 14 | const normalized_albums = payload.artist.album.map(album => album.id) 15 | // As only the details of one artist is displayed at a time 16 | // we can replace the existing content of byId with the new artist 17 | return { 18 | ...state, 19 | byId : { 20 | [payload.artist.id] : { 21 | ...payload.artist, 22 | album : normalized_albums 23 | } 24 | } 25 | } 26 | }, 27 | [types.LOGOUT_USER]: (state, payload) => initialState.artists, 28 | }) -------------------------------------------------------------------------------- /src/redux/reducers/asyncTasksReducer.js: -------------------------------------------------------------------------------- 1 | 2 | import * as types from "../actions/actionTypes" 3 | import initialState from "./initialState" 4 | import {createReducer} from '../../utils/redux.js' 5 | 6 | export default createReducer(initialState.asyncTasksInProgress, { 7 | [types.BEGIN_ASYNC_OPERATION]: (state, payload) => state + 1, 8 | [types.END_ASYNC_OPERATION]: (state, payload) => state - 1 9 | }) -------------------------------------------------------------------------------- /src/redux/reducers/authReducer.js: -------------------------------------------------------------------------------- 1 | import * as types from "../actions/actionTypes" 2 | import initialState from "./initialState" 3 | import {createReducer} from '../../utils/redux.js' 4 | 5 | export default createReducer(initialState.auth, { 6 | [types.LOGIN_USER_REQUEST]: (state, payload) => { 7 | return Object.assign({}, state, { 8 | 'isAuthenticating': true, 9 | 'statusText': null 10 | }) 11 | }, 12 | [types.LOGIN_USER_SUCCESS]: (state, payload) => { 13 | return Object.assign({}, state, { 14 | 'isAuthenticating': false, 15 | 'isAuthenticated': true, 16 | 'statusText': 'You have been successfully logged in.' 17 | }) 18 | 19 | }, 20 | [types.LOGIN_USER_FAILURE]: (state, payload) => { 21 | return Object.assign({}, state, { 22 | 'isAuthenticating': false, 23 | 'isAuthenticated': false, 24 | 'statusText': payload.statusText 25 | }) 26 | }, 27 | [types.LOGOUT_USER]: (state, payload) => { 28 | return Object.assign({}, state, { 29 | 'isAuthenticated': false, 30 | 'statusText': 'You have been successfully logged out.' 31 | }) 32 | }, 33 | [types.LAZY_LOGIN_IGNORE]: (state, payload) => { 34 | return Object.assign({}, state, { 35 | 'isAuthenticating': false, 36 | }) 37 | } 38 | }) -------------------------------------------------------------------------------- /src/redux/reducers/favouritesReducer.js: -------------------------------------------------------------------------------- 1 | import * as types from "../actions/actionTypes" 2 | import initialState from "./initialState" 3 | import {createReducer} from '../../utils/redux.js' 4 | 5 | export default createReducer(initialState.favourites, { 6 | [types.LOAD_FAVOURITES_RESULT] : (state, payload) => payload.songs.map(song => song.id), 7 | [types.STAR_SONG_RESULT] : (state, payload) => { 8 | let newSongs = null 9 | // Concatenate the faved songs (in a new array) if a new song was added 10 | if( payload.starred ) { 11 | newSongs = state.concat( payload.songIds ) 12 | } 13 | // Or create a new array without the deleted songs as the songs' array 14 | else { 15 | newSongs = state.filter(s => !payload.songIds.includes(s)) 16 | } 17 | return newSongs 18 | }, 19 | [types.LOGOUT_USER]: (state, payload) => initialState.favourites 20 | }) -------------------------------------------------------------------------------- /src/redux/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from "redux" 2 | import asyncTasksInProgress from "./asyncTasksReducer" 3 | import artists from "./artistsReducer" 4 | import playlists from "./playlistsReducer" 5 | import songs from "./songsReducer" 6 | import auth from "./authReducer" 7 | import search from "./searchReducer" 8 | import alert from "./alertsReducer" 9 | import albums from "./albumsReducer" 10 | import favourites from "./favouritesReducer" 11 | import musicPlayer from "./musicPlayerReducer" 12 | 13 | export default combineReducers({ 14 | asyncTasksInProgress, 15 | artists, 16 | playlists, 17 | songs, 18 | auth, 19 | search, 20 | alert, 21 | albums, 22 | favourites, 23 | musicPlayer 24 | }) -------------------------------------------------------------------------------- /src/redux/reducers/initialState.js: -------------------------------------------------------------------------------- 1 | import * as settings from "../../utils/settings" 2 | 3 | export default { 4 | asyncTasksInProgress: 0, 5 | alert : {}, 6 | artists : { 7 | byIndex : [], 8 | byId : {} 9 | }, 10 | albums : { 11 | byId : {} 12 | }, 13 | playlists : { 14 | byId : {} 15 | }, 16 | favourites : [], 17 | songs : { 18 | byId : {} 19 | }, 20 | musicPlayer : { 21 | queue : [], 22 | original : [], 23 | songsById : {}, 24 | currentSongIndex : null, 25 | currentSongId : null, 26 | isShuffleOn: settings.getIsShuffleOn(), 27 | }, 28 | auth : { 29 | isAuthenticated: false, 30 | isAuthenticating: true, 31 | statusText: null 32 | }, 33 | search : { 34 | albums : [], 35 | artists : [], 36 | songs : [], 37 | songsById : {} 38 | } 39 | } -------------------------------------------------------------------------------- /src/redux/reducers/searchReducer.js: -------------------------------------------------------------------------------- 1 | import * as types from "../actions/actionTypes" 2 | import initialState from "./initialState" 3 | import {createReducer, get_normalized_songs, set_starred_song_on_state} from '../../utils/redux.js' 4 | 5 | export default createReducer(initialState.search, { 6 | [types.SEARCH_RESULT]: (state, payload) => { 7 | return { 8 | 'albums': payload.album, 9 | 'artists': payload.artist, 10 | 'songs': payload.song ? payload.song.map(song => song.id) : [], 11 | 'songsById' : payload.song ? get_normalized_songs(payload.song) : {} 12 | } 13 | }, 14 | [types.STAR_SONG_RESULT] : (state, payload) => { 15 | let newState = state 16 | // Toggle if it is found in the DB of songs 17 | const modifiedSongsInDB = payload.songIds.filter(id => state.songsById[id]) 18 | modifiedSongsInDB.forEach( (id) => { 19 | newState = set_starred_song_on_state(newState, 'songsById', id, payload.starred) 20 | }) 21 | return newState 22 | }, 23 | [types.LOGOUT_USER]: (state, payload) => { 24 | return initialState.search 25 | } 26 | }) -------------------------------------------------------------------------------- /src/redux/reducers/songsReducer.js: -------------------------------------------------------------------------------- 1 | import * as types from "../actions/actionTypes" 2 | import initialState from "./initialState" 3 | import { createReducer, set_starred_song_on_state } from '../../utils/redux.js' 4 | 5 | function put_songs_in_store(state, songs, clearCurrentList = true) { 6 | // Transform the array of songs coming in the payload to a normalized object 7 | let normalized_songs = songs.reduce( (current,song) => ({...current, [song.id] : song }), {} ) 8 | // Replace the current songs if "clearCurrentList" or append the new songs to 9 | // the existing list 10 | const newSongs = clearCurrentList 11 | ? normalized_songs 12 | : {...state.byId, ...normalized_songs} 13 | return { ...state, byId : newSongs } 14 | } 15 | 16 | export default createReducer(initialState.songs, { 17 | [types.STAR_SONG_RESULT] : (state, payload) => { 18 | let newState = state 19 | // Toggle if it is found in the DB of songs 20 | const modifiedSongsInDB = payload.songIds.filter(id => state.byId[id]) 21 | modifiedSongsInDB.forEach( (id) => { 22 | newState = set_starred_song_on_state(newState, 'byId', id, payload.starred) 23 | }) 24 | return newState 25 | }, 26 | [types.LOAD_FAVOURITES_RESULT] : (state, payload) => put_songs_in_store(state, payload.songs, true), 27 | [types.LOAD_SONGS_OF_ONE_ARTIST_SUCCESS] : (state, payload) => put_songs_in_store(state, payload.songs, true), 28 | [types.LOAD_SONGS_OF_GENRE_SUCCESS] : (state, payload) => put_songs_in_store(state, payload.songs, true), 29 | [types.LOAD_ALBUM_SUCCESS] : (state, payload) => put_songs_in_store(state, payload.album.song, true), 30 | [types.LOAD_SINGLE_PLAYLIST_SUCCESS] : (state, payload) => put_songs_in_store(state, payload.playlist.entry, true), 31 | [types.LOGOUT_USER]: (state, payload) => initialState.songs 32 | }) -------------------------------------------------------------------------------- /src/redux/selectors/_tests_/albumSelectors.test.js: -------------------------------------------------------------------------------- 1 | import * as selectors from "../albumSelectors" 2 | 3 | describe('album selectors', () => { 4 | 5 | it('should filter the albums of an specific artist', () => { 6 | // All albums should go in the state 7 | const state = { 8 | albums : { 9 | byId : { 10 | '1' : { 11 | id : '1', 12 | artistId : 'nope' 13 | }, 14 | '2' : { 15 | id : '2', 16 | artistId : 'artist1' 17 | } 18 | } 19 | } 20 | } 21 | // Artist Id should go in props 22 | const props = { artistId : 'artist1' } 23 | // Albums should have only album 2 24 | const albums = selectors.getAlbumsOfArtist(state, props) 25 | expect(albums).toEqual( [state.albums.byId['2']] ) 26 | }) 27 | 28 | it("should get a list of all albums", () => { 29 | // All albums should go in the state 30 | const state = { 31 | albums : { 32 | byId : { 33 | '1' : { 34 | id : '1', 35 | artistId : 'nope' 36 | }, 37 | '2' : { 38 | id : '2', 39 | artistId : 'artist1' 40 | } 41 | } 42 | } 43 | } 44 | // all albums should be retrieved as an array 45 | const albums = selectors.albumsSelector(state) 46 | expect(albums).toEqual( [state.albums.byId['1'], state.albums.byId['2']] ) 47 | }) 48 | 49 | 50 | }) -------------------------------------------------------------------------------- /src/redux/selectors/_tests_/artistSelectors.test.js: -------------------------------------------------------------------------------- 1 | import * as selectors from "../artistSelectors" 2 | 3 | describe('artist selectors', () => { 4 | 5 | it('should flatten the aritst.byIndex object', () => { 6 | // All artists should go in the state 7 | const state = { 8 | artists : { 9 | byIndex : [ 10 | { name : "#", artist : [{id : "1"}] }, 11 | { name : "A", artist : [{id : "2"}, {id: "3"}] }, 12 | ] 13 | } 14 | } 15 | // Albums should have only album 2 16 | const artists = selectors.getArtistsWithHeaders(state) 17 | expect(artists).toEqual([ {header:"#"}, {id:"1"}, {header:"A"}, {id:"2"}, {id:"3"} ]) 18 | }) 19 | 20 | 21 | }) -------------------------------------------------------------------------------- /src/redux/selectors/_tests_/musicPlayerSelector.test.js: -------------------------------------------------------------------------------- 1 | import * as selectors from "../musicPlayerSelector" 2 | 3 | describe('music player selectors', () => { 4 | 5 | it('should get the metadata of the song currently playing', () => { 6 | // Song playing is song '2' which is at position 1 of the queue 7 | const state = { 8 | musicPlayer : { 9 | queue : ["1", "2"], 10 | songsById : { 11 | "1" : { id : '1' }, 12 | "2" : { 13 | id : '2', 14 | name : 'name 2' 15 | } 16 | }, 17 | currentSongIndex : 1, 18 | currentSongId : '2', 19 | } 20 | } 21 | // Should return song '2' 22 | const currentSongPlaying = selectors.getSongCurrentlyPlayingSelector(state) 23 | expect(currentSongPlaying).toEqual( state.musicPlayer.songsById['2'] ) 24 | }) 25 | 26 | it('should get the details of the songs pending in the queue', () => { 27 | // Song playing is song '2' which is at position 1 of the queue 28 | const state = { 29 | musicPlayer : { 30 | queue : ["1", "2", "3"], 31 | songsById : { 32 | "1" : { id : '1' }, 33 | "2" : { id : '2' }, 34 | "3" : { id : '3' }, 35 | }, 36 | currentSongIndex : 1, 37 | currentSongId : '2', 38 | } 39 | } 40 | // Should return songs '2' (the one currently playing) and '3' 41 | const pendingSongs = selectors.getSongsInQueueSelector(state) 42 | expect(pendingSongs).toEqual( [state.musicPlayer.songsById['2'],state.musicPlayer.songsById['3']] ) 43 | }) 44 | 45 | 46 | }) -------------------------------------------------------------------------------- /src/redux/selectors/_tests_/searchSelectors.test.js: -------------------------------------------------------------------------------- 1 | import * as selectors from "../searchSelectors" 2 | 3 | describe('search selectors', () => { 4 | 5 | it('should get the metadata of the songs in the search results', () => { 6 | // The search results have 2 songs 7 | const state = { 8 | search : { 9 | songs : ['2', '1'], 10 | songsById : { 11 | '1' : { id : '1'}, 12 | '2' : { id : '2'} 13 | } 14 | } 15 | } 16 | // Should return an array containing song '2' and '1' 17 | const songs = selectors.searchSongsSelector(state) 18 | expect(songs).toEqual( [ state.search.songsById['2'], state.search.songsById['1'] ] ) 19 | }) 20 | 21 | 22 | }) -------------------------------------------------------------------------------- /src/redux/selectors/albumSelectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect' 2 | 3 | const getAlbums = (state) => Object.keys(state.albums.byId).map(id => state.albums.byId[id]) 4 | 5 | const getArtistIdFromProps = (state, props) => props.artistId 6 | 7 | export const getAlbumsOfArtist = createSelector( 8 | [ getArtistIdFromProps, getAlbums ], 9 | (artistId, albums) => { 10 | return albums.filter(album => album.artistId === artistId) 11 | } 12 | ) 13 | 14 | export const albumsSelector = createSelector( 15 | [getAlbums], 16 | albums => albums 17 | ) 18 | -------------------------------------------------------------------------------- /src/redux/selectors/artistSelectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect' 2 | 3 | const getArtistsByIndex = (state) => state.artists.byIndex 4 | 5 | // Returns the artists.byIndex element in a flat list with its headers 6 | export const getArtistsWithHeaders = createSelector( 7 | getArtistsByIndex, 8 | artists => artists 9 | .reduce( (accum, current) => accum.concat( [ {header: current.name}, ...current.artist] ), [] ) 10 | ) 11 | -------------------------------------------------------------------------------- /src/redux/selectors/musicPlayerSelector.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect' 2 | 3 | const getQueue = (state) => state.musicPlayer.queue 4 | 5 | const getQueueSongs = (state) => state.musicPlayer.songsById 6 | 7 | const getCurrentlyPlayingIndex = (state) => state.musicPlayer.currentSongIndex 8 | 9 | const getCurrentlyPlayingId = (state) => state.musicPlayer.currentSongId 10 | 11 | export const getSongCurrentlyPlayingSelector = createSelector( 12 | [getCurrentlyPlayingId, getQueueSongs], 13 | (currentId, songs) => songs[currentId] 14 | ) 15 | 16 | // Only return the songs pending to be played 17 | export const getSongsInQueueSelector = createSelector( 18 | [getQueue, getCurrentlyPlayingIndex, getQueueSongs], 19 | (queue, currentIndex, songs) => currentIndex !== null ? queue.slice(currentIndex).map( id => songs[id] ) : [] 20 | ) -------------------------------------------------------------------------------- /src/redux/selectors/searchSelectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect' 2 | 3 | const getSearchSongsById = (state) => state.search.songsById 4 | 5 | const getSearchSongsIds = (state) => state.search.songs 6 | 7 | export const searchSongsSelector = createSelector( 8 | [getSearchSongsById, getSearchSongsIds], 9 | (songs, songIds) => songIds.map(id => songs[id]) 10 | ) 11 | -------------------------------------------------------------------------------- /src/redux/selectors/songSelectors.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect' 2 | 3 | const getPlaylist = (state, props) => state.playlists.byId[props.playlistId] 4 | 5 | const getAlbum = (state, props) => state.albums.byId[props.albumId] 6 | 7 | const getSongs = (state, props) => state.songs.byId 8 | 9 | const getFavourites = (state, props) => state.favourites 10 | 11 | export const makeGetSongsOfAlbum = () => { 12 | return createSelector ( 13 | [ getAlbum, getSongs ], 14 | (album, songs) => { 15 | return (album && album.song) 16 | ? album.song.reduce( (accum,songId) => { 17 | if( songs[songId] ) { 18 | accum.push(songs[songId]) 19 | } 20 | return accum 21 | }, []) 22 | : [] 23 | } 24 | ) 25 | } 26 | 27 | export const songsSelector = createSelector( 28 | [getSongs], 29 | songs => Object.keys(songs).map(id => songs[id]) 30 | ) 31 | 32 | export const songsOfArtistSelector = createSelector( 33 | [getSongs, (state, props) => props.artistId ], 34 | (songs, artistId) => { 35 | return Object.keys(songs).map(id => songs[id]).filter(song => song.artistId === artistId) 36 | } 37 | ) 38 | 39 | export const songsOfGenreSelector = createSelector( 40 | [getSongs, (state, props) => props.genre.value ], 41 | (songs, genre) => { 42 | return Object.keys(songs).map(id => songs[id]).filter(song => song.genre === genre) 43 | } 44 | ) 45 | 46 | export const songsOfPlaylistSelector = createSelector( 47 | [getPlaylist, getSongs], 48 | (playlist, songs) => { 49 | return (playlist && playlist.songs) 50 | ? playlist.songs.reduce( (accum,songId) => { 51 | if( songs[songId] ) { 52 | accum.push(songs[songId]) 53 | } 54 | return accum 55 | }, []) 56 | : [] 57 | } 58 | ) 59 | 60 | export const favouriteSongsSelector = createSelector( 61 | [getSongs, getFavourites], 62 | (songs, favourites) => { 63 | return favourites.reduce( (accum,songId) => { 64 | if( songs[songId] ) { 65 | accum.push(songs[songId]) 66 | } 67 | return accum 68 | }, []) 69 | } 70 | ) -------------------------------------------------------------------------------- /src/setupTests.js: -------------------------------------------------------------------------------- 1 | import { configure } from 'enzyme' 2 | import Adapter from 'enzyme-adapter-react-16' 3 | import enableHooks from 'jest-react-hooks-shallow' 4 | 5 | configure({ adapter: new Adapter() }) 6 | // pass an instance of jest to `enableHooks()` 7 | enableHooks(jest) -------------------------------------------------------------------------------- /src/utils/formatting.js: -------------------------------------------------------------------------------- 1 | 2 | // Credit to: https://stackoverflow.com/a/37770048/3835750 3 | export function seconds_to_mss(s){ 4 | let display = "0:00" 5 | if( !isNaN(s) ) { 6 | display = (s-(s%=60))/60+(9 0 ? days + (days === 1 ? " day " : " days ") : "" 22 | var hDisplay = h > 0 ? h + (h === 1 ? " hr " : " hrs ") : "" 23 | var mDisplay = m > 0 ? m + (m === 1 ? " mins" : " mins") : "" 24 | // var sDisplay = s > 0 ? s + (s === 1 ? " s" : " s") : "" 25 | display = dDisplay + hDisplay + mDisplay 26 | } 27 | return display 28 | } 29 | 30 | export const display_starred = (starred) => starred.split("T")[0] -------------------------------------------------------------------------------- /src/utils/settings.js: -------------------------------------------------------------------------------- 1 | 2 | /* Scrobble Settings */ 3 | const DEFAULT_IS_SCROBBLING = true 4 | 5 | export function getIsScrobbling() { 6 | const value = localStorage.getItem('is_scrobbling') 7 | return value !== null ? value === "true" : DEFAULT_IS_SCROBBLING 8 | } 9 | 10 | export function setIsScrobbling(value) { 11 | localStorage.setItem('is_scrobbling', value) 12 | } 13 | 14 | /* Volume Settings */ 15 | const DEFAULT_VOLUME = 1.0 16 | 17 | export function getVolume() { 18 | const value = localStorage.getItem('volume') 19 | return value !== null ? parseFloat(value) : DEFAULT_VOLUME 20 | } 21 | 22 | export function setVolume(value) { 23 | localStorage.setItem('volume', value) 24 | } 25 | 26 | /* Shuffle Settings */ 27 | const DEFAULT_IS_SHUFFLING = true 28 | 29 | export function getIsShuffleOn() { 30 | const value = localStorage.getItem('is_shuffling') 31 | return value !== null ? value === "true" : DEFAULT_IS_SHUFFLING 32 | } 33 | 34 | export function setShuffle(value) { 35 | localStorage.setItem('is_shuffling', value) 36 | } 37 | 38 | /* Sidebar settings */ 39 | export const POSSIBLE_SIDEBAR_LINKS = [ 40 | {key:"/latest" , icon: "clock-o", text:"Recently Added"}, 41 | {key:"/artists" , icon: "group", text:"Artists"}, 42 | {key:"/album" , icon: "th2", text:"Albums"}, 43 | {key:"/genres" , icon: "venus-mars", text:"Genres"}, 44 | {key:"/favourites" , icon: "star", text:"Favourites"}, 45 | ] 46 | 47 | export function getSidebarDisplaySettings(mobile=false) { 48 | // Return all the possible items if the client is a mobile as we assume 49 | // they don't take too much space 50 | let items = POSSIBLE_SIDEBAR_LINKS 51 | if( !mobile ) { 52 | // if the client is not mobile, return the filtered items 53 | let savedItems = localStorage.getItem('sidebar_settings') 54 | if( savedItems !== null) { 55 | savedItems = JSON.parse(savedItems) 56 | items = POSSIBLE_SIDEBAR_LINKS.filter(item => savedItems.includes(item.key)) 57 | } 58 | } 59 | return items 60 | } 61 | 62 | export function setSidebarDisplaySettings(value) { 63 | localStorage.setItem('sidebar_settings', JSON.stringify(value.map(v => v.key)) ) 64 | } -------------------------------------------------------------------------------- /src/utils/theming.js: -------------------------------------------------------------------------------- 1 | 2 | export function getAvailableThemes() { 3 | return require('../themes.config') 4 | } 5 | 6 | export function formatName(name) { 7 | let result = name 8 | // Show as light or dark theme 9 | const normalized = name.toLowerCase() 10 | if(isThemeLight(name)){ 11 | result = `Light ${normalized.split("light")[1]}` 12 | } 13 | else if(isThemeDark(name)){ 14 | result = `Dark ${normalized.split("dark")[1]}` 15 | } 16 | return result 17 | } 18 | 19 | export function isThemeLight(name) { 20 | return name.toLowerCase().startsWith("light") 21 | } 22 | 23 | export function isThemeDark(name) { 24 | return name.toLowerCase().startsWith("dark") 25 | } 26 | 27 | export function getThemeType(name){ 28 | let result = "" 29 | if(isThemeLight(name)){ 30 | result = "light" 31 | } 32 | else if(isThemeDark(name)){ 33 | result = "dark" 34 | } 35 | return result 36 | } 37 | 38 | export function changeTheme(theme) { 39 | // Replace theme in memory 40 | localStorage.setItem('theme', theme) 41 | // Load new theme 42 | loadTheme(theme) 43 | } 44 | 45 | export function loadTheme(theme) { 46 | const file = `${process.env.PUBLIC_URL}/css/${theme}.css` 47 | // Put stylesheet in the document 48 | const link = document.createElement('link') 49 | link.rel = 'stylesheet'; 50 | link.href = file; 51 | link.dataset.theme = theme; 52 | document.head.appendChild(link) 53 | } 54 | 55 | export function initTheme() { 56 | const savedTheme = localStorage.getItem('theme') 57 | const defaultTheme = "darkOrange" 58 | loadTheme(savedTheme || defaultTheme) 59 | } -------------------------------------------------------------------------------- /src/utils/utils.js: -------------------------------------------------------------------------------- 1 | 2 | export function isPlaylistMineByOwner(owner){ return owner === localStorage.getItem('username') } 3 | 4 | // From: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/sort 5 | export function sortSongsByKey(songs, key, type) { 6 | return new Promise( (resolve, reject) => { 7 | let sortedArray = [...songs] 8 | sortedArray.sort( (a,b) => { 9 | var nameA = a[key].toUpperCase() // ignore upper and lowercase 10 | var nameB = b[key].toUpperCase() // ignore upper and lowercase 11 | if (nameA < nameB) { 12 | return type === "asc" ? -1 : 1 13 | } 14 | if (nameA > nameB) { 15 | return type === "asc" ? 1 : -1 16 | } 17 | // names must be equal 18 | return 0 19 | }) 20 | resolve(sortedArray) 21 | }) 22 | } 23 | 24 | export function filterSongsByValue(songs, filter) { 25 | return new Promise( (resolve, reject) => { 26 | // Check if there is a filter to apply 27 | if( filter ){ 28 | // ignore upper and lowercase 29 | const fixedFilter = filter.toUpperCase() 30 | // Look for songs with this filter value in the: 31 | // title, artist or album, which are the most common keys someone 32 | // would like to filter 33 | const filteredArray = songs.filter( song => { 34 | return song.title.toUpperCase().indexOf(fixedFilter) !== -1 || 35 | song.artist.toUpperCase().indexOf(fixedFilter) !== -1 || 36 | song.album.toUpperCase().indexOf(fixedFilter) !== -1 37 | }) 38 | resolve(filteredArray) 39 | } 40 | else { 41 | resolve(songs) 42 | } 43 | }) 44 | } 45 | 46 | export function computeJointDurationOfSongs(songs) { 47 | return songs.reduce( (a,b) => ({duration: a.duration+b.duration}), {duration:0} ).duration 48 | } -------------------------------------------------------------------------------- /themes.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // ORANGE 3 | lightOrange: { 4 | 'base-color': '#ff3d00' 5 | }, 6 | darkOrange: { 7 | 'base-color': '#ff3d00' 8 | }, 9 | // BLUE 10 | lightBlue: { 11 | 'base-color': '#3498ff' 12 | }, 13 | darkBlue: { 14 | 'base-color': '#3498ff' 15 | }, 16 | // GREEN 17 | lightGreen: { 18 | 'base-color': '#429321' 19 | }, 20 | darkGreen: { 21 | 'base-color': '#429321' 22 | }, 23 | // GREY 24 | lightGrey: { 25 | 'base-color': '#607d8b' 26 | }, 27 | darkGrey: { 28 | 'base-color': '#607d8b' 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /themes.content.js: -------------------------------------------------------------------------------- 1 | module.exports = (themeName) => { 2 | const isDark = themeName.startsWith("dark") 3 | return ( 4 | ` 5 | @import '~rsuite/lib/styles/themes/${isDark ? "dark" : "default"}/index.less'; 6 | 7 | .rc-slider-rail { 8 | .rs-slider-bar 9 | } 10 | 11 | .rc-slider-track { 12 | .rs-slider-progress-bar 13 | } 14 | 15 | .rc-slider-handle { 16 | width: @slider-handle-diameter; 17 | height: @slider-handle-diameter; 18 | border: @slider-handle-border-width solid @slider-handle-default-border-color; 19 | background-color: @slider-handle-default-bg; 20 | top: 50%; 21 | 22 | &:hover { 23 | box-shadow: @slider-handle-hover-box-shadow; 24 | transition: box-shadow @slider-handle-transition, background-color @slider-handle-transition, 25 | transform @slider-handle-transition; 26 | border: @slider-handle-border-width solid @slider-handle-default-border-color; 27 | cursor: pointer; 28 | } 29 | 30 | &:active { 31 | box-shadow: none; 32 | transform: scale(1.2); 33 | border: @slider-handle-border-width solid @slider-handle-default-border-color; 34 | cursor: pointer; 35 | } 36 | 37 | &-click-focused:focus { 38 | border: @slider-handle-border-width solid @slider-handle-default-border-color; 39 | } 40 | } 41 | 42 | .loader:before { 43 | background-color: @base-color; 44 | } 45 | 46 | .music-player { 47 | background-color: ${isDark ? "@B700" : "#fff"}; 48 | border-top : ${isDark ? "none" : "1px solid #f0f0f0"}; 49 | } 50 | 51 | .music-player .rs-icon-inverse { 52 | color: ${isDark ? "#3b3f43" : "#f2f2f5"} 53 | } 54 | 55 | .artist-header { 56 | color: ${isDark ? "@base-color" : "inherit"}; 57 | } 58 | 59 | .currently-playing { 60 | color: @base-color; 61 | font-weight: bold; 62 | } 63 | 64 | .link_to_artist:hover { 65 | background-color: @nav-item-default-hover-bg; 66 | cursor: pointer; 67 | color: @base-color; 68 | font-weight: bold; 69 | } 70 | 71 | .link_to_album:hover { 72 | background-color: @nav-item-default-hover-bg; 73 | cursor: pointer; 74 | color: @base-color; 75 | font-weight: bold; 76 | } 77 | 78 | .theme-element:hover { 79 | background-color: @nav-item-default-hover-bg; 80 | cursor: pointer; 81 | font-weight: bold; 82 | } 83 | 84 | @scrollbar-width: 8px; 85 | ` 86 | ) 87 | } 88 | --------------------------------------------------------------------------------