├── src ├── react-app-env.d.ts ├── img │ ├── banner.png │ ├── logo.png │ ├── github-logo.png │ ├── sort-page-demo.png │ ├── tools-page-demo.png │ ├── compare-page-demo.png │ └── spotify-logo-round.png ├── index.css ├── pages │ ├── Tools │ │ ├── filter.ts │ │ ├── FilterReverse.tsx │ │ ├── FilterDistinct.tsx │ │ ├── FilterRandomise.tsx │ │ ├── FilterOrderByAudioFeature.tsx │ │ ├── FilterAddPlaylists.tsx │ │ ├── TrackTable.tsx │ │ ├── FilterAudioFeaturePredicate.tsx │ │ └── index.tsx │ ├── NotFound │ │ └── index.tsx │ ├── Sort │ │ ├── TrackSortControl.tsx │ │ ├── TrackTable.tsx │ │ ├── PlotTracks.tsx │ │ └── index.tsx │ ├── Compare │ │ ├── RadarChartAudioFeatureComparison.tsx │ │ ├── BoxPlotAudioFeatureComparison.tsx │ │ ├── ScatterPlotDualAudioFeatureComparison.tsx │ │ └── index.tsx │ ├── Home │ │ └── index.tsx │ ├── SpotifyAuthorization │ │ └── index.tsx │ └── About │ │ └── index.tsx ├── config.ts ├── hooks │ ├── ScrollToTopOnRouteChange.ts │ ├── NavigatorOnline.ts │ └── WindowSize.ts ├── components │ ├── Footer.css │ ├── MetaTags.tsx │ ├── Footer.tsx │ ├── NamedDropdown.tsx │ ├── SpotifyLoginStatusButton.tsx │ ├── PlaylistDetails.tsx │ ├── Navigation.tsx │ ├── StoredDataDialog.tsx │ ├── TokenRefreshWarning.tsx │ ├── ExportPlaylistInput.tsx │ └── PlaylistSelection.tsx ├── index.tsx ├── logic │ ├── Utils.ts │ ├── PointSorting.ts │ └── Spotify.ts ├── models │ └── Spotify.ts ├── serviceWorker.ts └── App.tsx ├── public ├── favicon.png ├── manifest.json └── index.html ├── .prettierrc ├── .vscode ├── extensions.json ├── settings.json └── launch.json ├── .gitignore ├── tsconfig.json ├── tools ├── generate-sitemap.ts └── copy-index.ts ├── .github └── workflows │ └── main.yml ├── package.json └── README.md /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brentvollebregt/emotionify/HEAD/public/favicon.png -------------------------------------------------------------------------------- /src/img/banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brentvollebregt/emotionify/HEAD/src/img/banner.png -------------------------------------------------------------------------------- /src/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brentvollebregt/emotionify/HEAD/src/img/logo.png -------------------------------------------------------------------------------- /src/img/github-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brentvollebregt/emotionify/HEAD/src/img/github-logo.png -------------------------------------------------------------------------------- /src/img/sort-page-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brentvollebregt/emotionify/HEAD/src/img/sort-page-demo.png -------------------------------------------------------------------------------- /src/img/tools-page-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brentvollebregt/emotionify/HEAD/src/img/tools-page-demo.png -------------------------------------------------------------------------------- /src/img/compare-page-demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brentvollebregt/emotionify/HEAD/src/img/compare-page-demo.png -------------------------------------------------------------------------------- /src/img/spotify-logo-round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brentvollebregt/emotionify/HEAD/src/img/spotify-logo-round.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "eslintIntegration": true, 3 | "printWidth": 100, 4 | "trailingComma": "none", 5 | "endOfLine": "lf" 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "ms-vscode.vscode-typescript-tslint-plugin", 4 | "esbenp.prettier-vscode" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.rulers": [100], 4 | "editor.tabSize": 2, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.tslint": "explicit" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", 4 | "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; 5 | -webkit-font-smoothing: antialiased; 6 | -moz-osx-font-smoothing: grayscale; 7 | } 8 | -------------------------------------------------------------------------------- /src/pages/Tools/filter.ts: -------------------------------------------------------------------------------- 1 | import { TrackWithAudioFeatures } from "../../models/Spotify"; 2 | 3 | export interface FilterFunctionProps { 4 | outputCallback: ( 5 | filter: ((tracks: TrackWithAudioFeatures[]) => TrackWithAudioFeatures[]) | undefined, 6 | titleText: string 7 | ) => void; 8 | } 9 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | const config = { 2 | spotify: { 3 | clientId: "3d402278ec5e45bc930e791de2741b3e", 4 | permissionScope: 5 | "playlist-read-private user-read-private playlist-modify-private playlist-modify-public" 6 | }, 7 | siteUrl: "https://emotionify.nitratine.net" 8 | }; 9 | 10 | export default config; 11 | -------------------------------------------------------------------------------- /src/hooks/ScrollToTopOnRouteChange.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { usePath } from "hookrouter"; 3 | 4 | function useScrollToTopOnRouteChange() { 5 | const path = usePath(); 6 | 7 | useEffect(() => { 8 | window.scrollTo(0, 0); 9 | }, [path]); 10 | } 11 | 12 | export default useScrollToTopOnRouteChange; 13 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Emotionify", 3 | "name": "Emotionify", 4 | "icons": [ 5 | { 6 | "src": "favicon.png", 7 | "sizes": "96x96", 8 | "type": "image/png" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /.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 | public/sitemap.xml -------------------------------------------------------------------------------- /src/components/Footer.css: -------------------------------------------------------------------------------- 1 | /* 2 | Sticky footer styles 3 | (https://getbootstrap.com/docs/4.0/examples/sticky-footer/) 4 | */ 5 | 6 | html { 7 | position: relative; 8 | min-height: 100%; 9 | } 10 | 11 | body { 12 | margin-bottom: 80px; /* 50px + 30px (height + margin) */ 13 | } 14 | 15 | .footer { 16 | position: absolute; 17 | bottom: 0; 18 | width: 100%; 19 | height: 50px; 20 | margin-top: 30px; 21 | background-color: #f5f5f5; 22 | } 23 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "launch", 10 | "name": "Launch Chrome against localhost", 11 | "url": "http://localhost:3000", 12 | "webRoot": "${workspaceFolder}" 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/pages/NotFound/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { navigate } from "hookrouter"; 3 | import { Button, Container } from "react-bootstrap"; 4 | 5 | const NotFound: React.FunctionComponent = () => { 6 | const navigateHome = () => navigate("/"); 7 | 8 | return ( 9 | 10 |

Page Not Found

11 | 12 |
13 | ); 14 | }; 15 | 16 | export default NotFound; 17 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import "array-flat-polyfill"; 2 | import React from "react"; 3 | import ReactDOM from "react-dom"; 4 | import "./index.css"; 5 | import App from "./App"; 6 | import * as serviceWorker from "./serviceWorker"; 7 | 8 | ReactDOM.render(, document.getElementById("root")); 9 | 10 | // If you want your app to work offline and load faster, you can change 11 | // unregister() to register() below. Note this comes with some pitfalls. 12 | // Learn more about service workers: https://bit.ly/CRA-PWA 13 | serviceWorker.unregister(); 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "preserve" 17 | }, 18 | "include": ["src", "tools"] 19 | } 20 | -------------------------------------------------------------------------------- /tools/generate-sitemap.ts: -------------------------------------------------------------------------------- 1 | import { SitemapStream, streamToPromise } from "sitemap"; 2 | import { Readable } from "stream"; 3 | import fs from "fs"; 4 | import config from "../src/config"; 5 | 6 | const rootDestinations = ["/", "/sort/", "/compare/", "/tools/", "/about/"]; 7 | 8 | const links = rootDestinations.map((dest) => ({ url: `${dest}`, priority: 0.8 })); 9 | 10 | const stream = new SitemapStream({ hostname: config.siteUrl }); 11 | streamToPromise(Readable.from(links).pipe(stream)).then((data) => { 12 | fs.writeFileSync("./public/sitemap.xml", data); 13 | console.log("Sitemap created."); 14 | }); 15 | -------------------------------------------------------------------------------- /src/pages/Tools/FilterReverse.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { FilterFunctionProps } from "./filter"; 3 | import { TrackWithAudioFeatures } from "../../models/Spotify"; 4 | 5 | interface IProps extends FilterFunctionProps {} 6 | 7 | const filter = (tracks: TrackWithAudioFeatures[]): TrackWithAudioFeatures[] => tracks.reverse(); 8 | 9 | const FilterReverse: React.FunctionComponent = (props: IProps) => { 10 | const { outputCallback } = props; 11 | 12 | useEffect(() => { 13 | outputCallback(filter, "Reverse song order"); 14 | }, []); 15 | 16 | return ( 17 | <> 18 |

Reverse all tracks

19 | 20 | ); 21 | }; 22 | 23 | export default FilterReverse; 24 | -------------------------------------------------------------------------------- /tools/copy-index.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | 3 | const rootDestinations = ["sort", "compare", "tools", "about"]; 4 | 5 | // Create a 404 fallback for GitHub Pages 6 | fs.copyFile("build/index.html", `build/404.html`, (err) => { 7 | if (err) throw err; 8 | console.log(`Copied build/index.html to build/404.html`); 9 | }); 10 | 11 | // Copy index.html to path folders to be served by GitHub Pages 12 | rootDestinations.forEach((dest) => { 13 | const fullDestination = `build/${dest}`; 14 | if (!fs.existsSync(fullDestination)) { 15 | fs.mkdirSync(fullDestination); 16 | } 17 | fs.copyFile("build/index.html", `${fullDestination}/index.html`, (err) => { 18 | if (err) throw err; 19 | console.log(`Copied build/index.html to ${fullDestination}/index.html`); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/pages/Tools/FilterDistinct.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { FilterFunctionProps } from "./filter"; 3 | import { TrackWithAudioFeatures } from "../../models/Spotify"; 4 | 5 | interface IProps extends FilterFunctionProps {} 6 | 7 | const filter = (tracks: TrackWithAudioFeatures[]): TrackWithAudioFeatures[] => 8 | tracks.filter((value, index, self) => self.findIndex((t) => t.id === value.id) === index); 9 | 10 | const FilterDistinct: React.FunctionComponent = (props: IProps) => { 11 | const { outputCallback } = props; 12 | 13 | useEffect(() => { 14 | outputCallback(filter, "Remove duplicate songs"); 15 | }, []); 16 | 17 | return ( 18 | <> 19 |

Remove duplicate songs

20 | 21 | ); 22 | }; 23 | 24 | export default FilterDistinct; 25 | -------------------------------------------------------------------------------- /src/hooks/NavigatorOnline.ts: -------------------------------------------------------------------------------- 1 | // Modified Source: https://github.com/oieduardorabelo/use-navigator-online 2 | 3 | import { useEffect, useState } from "react"; 4 | 5 | function useNavigatorOnline() { 6 | let [isOnline, setIsOnline] = useState(window.navigator.onLine); 7 | 8 | useEffect(() => { 9 | function handleOnlineStatusChange(event: Event) { 10 | setIsOnline(window.navigator.onLine); 11 | } 12 | 13 | window.addEventListener("online", handleOnlineStatusChange); 14 | window.addEventListener("offline", handleOnlineStatusChange); 15 | 16 | return () => { 17 | window.removeEventListener("online", handleOnlineStatusChange); 18 | window.removeEventListener("offline", handleOnlineStatusChange); 19 | }; 20 | }, []); 21 | 22 | return isOnline; 23 | } 24 | 25 | export default useNavigatorOnline; 26 | -------------------------------------------------------------------------------- /src/hooks/WindowSize.ts: -------------------------------------------------------------------------------- 1 | // Modified Source: https://github.com/rehooks/window-size 2 | 3 | import { useState, useEffect } from "react"; 4 | 5 | interface WindowDimensions { 6 | innerHeight: number; 7 | innerWidth: number; 8 | outerHeight: number; 9 | outerWidth: number; 10 | } 11 | 12 | function getSize(): WindowDimensions { 13 | return { 14 | innerHeight: window.innerHeight, 15 | innerWidth: window.innerWidth, 16 | outerHeight: window.outerHeight, 17 | outerWidth: window.outerWidth 18 | }; 19 | } 20 | 21 | export default function useWindowSize(): WindowDimensions { 22 | let [windowSize, setWindowSize] = useState(getSize()); 23 | 24 | function handleResize() { 25 | setWindowSize(getSize()); 26 | } 27 | 28 | useEffect(() => { 29 | window.addEventListener("resize", handleResize); 30 | return () => { 31 | window.removeEventListener("resize", handleResize); 32 | }; 33 | }, []); 34 | 35 | return windowSize; 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy to gh-pages 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | build-deploy: 11 | runs-on: ubuntu-latest 12 | permissions: 13 | contents: write 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - name: Setup Node 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: 10 21 | 22 | - name: Install Dependencies and Build 23 | run: | 24 | npm install 25 | npm run build 26 | env: 27 | GENERATE_SOURCEMAP: "false" 28 | CI: false 29 | 30 | - name: Deploy 31 | uses: peaceiris/actions-gh-pages@v3 32 | with: 33 | github_token: ${{ secrets.GITHUB_TOKEN }} 34 | publish_dir: ./build 35 | publish_branch: gh-pages 36 | cname: emotionify.nitratine.net 37 | force_orphan: true # Only keep latest commit in gh-pages (to keep repo size down) 38 | -------------------------------------------------------------------------------- /src/pages/Tools/FilterRandomise.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { FilterFunctionProps } from "./filter"; 3 | import { TrackWithAudioFeatures } from "../../models/Spotify"; 4 | 5 | interface IProps extends FilterFunctionProps {} 6 | 7 | const filter = (tracks: TrackWithAudioFeatures[]): TrackWithAudioFeatures[] => { 8 | let randomised_tracks = [...tracks]; 9 | for (let i = randomised_tracks.length - 1; i > 0; i--) { 10 | const j = Math.floor(Math.random() * (i + 1)); 11 | [randomised_tracks[i], randomised_tracks[j]] = [randomised_tracks[j], randomised_tracks[i]]; 12 | } 13 | return randomised_tracks; 14 | }; 15 | 16 | const FilterRandomise: React.FunctionComponent = (props: IProps) => { 17 | const { outputCallback } = props; 18 | 19 | useEffect(() => { 20 | outputCallback(filter, "Randomise song order"); 21 | }, []); 22 | 23 | return ( 24 | <> 25 |

Randomise song order

26 | 27 | ); 28 | }; 29 | 30 | export default FilterRandomise; 31 | -------------------------------------------------------------------------------- /src/components/MetaTags.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import useMetaTags from "react-metatags-hook"; 3 | import BannerImage from "../img/banner.png"; 4 | import config from "../config"; 5 | 6 | interface IProps { 7 | titlePrefix?: string; 8 | description: string; 9 | route: string; 10 | } 11 | 12 | const MetaTags: React.FunctionComponent> = ({ 13 | titlePrefix, 14 | description, 15 | route, 16 | children 17 | }) => { 18 | const title = (titlePrefix || "") + "Emotionify"; 19 | useMetaTags({ 20 | title, 21 | description, 22 | charset: "utf-8", 23 | lang: "en", 24 | links: [ 25 | { rel: "canonical", href: config.siteUrl + route }, 26 | { rel: "icon", type: "image/ico", href: "/favicon.ico" }, 27 | { rel: "apple-touch-icon", type: "image/png", href: "/logo.png" } 28 | ], 29 | openGraph: { 30 | title, 31 | image: config.siteUrl + BannerImage, 32 | site_name: "Emotionify" 33 | } 34 | }); 35 | 36 | return <>{children}; 37 | }; 38 | 39 | export default MetaTags; 40 | -------------------------------------------------------------------------------- /src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Container } from "react-bootstrap"; 3 | import GithubLogo from "../img/github-logo.png"; 4 | import "./Footer.css"; 5 | 6 | const Footer: React.FunctionComponent = () => { 7 | return ( 8 | 30 | ); 31 | }; 32 | 33 | export default Footer; 34 | -------------------------------------------------------------------------------- /src/logic/Utils.ts: -------------------------------------------------------------------------------- 1 | // Convert an object to a query string 2 | export function encodeData(data: any): string { 3 | return Object.keys(data) 4 | .map(function (key) { 5 | return [key, data[key]].map(encodeURIComponent).join("="); 6 | }) 7 | .join("&"); 8 | } 9 | 10 | // Create a random string of n length 11 | export const randomString = (length: number) => { 12 | const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 13 | const values = crypto.getRandomValues(new Uint8Array(length)); 14 | return values.reduce((acc, x) => acc + possible[x % possible.length], ""); 15 | }; 16 | 17 | // Turn a list into lists of lists with each top level list holding a max of `chunk_amount` lists 18 | export function chunkList(list: T[], chunk_amount: number): T[][] { 19 | let chunks: T[][] = []; 20 | for (let i = 0; i < list.length; i += chunk_amount) { 21 | chunks = [...chunks, list.slice(i, i + chunk_amount)]; 22 | } 23 | return chunks; 24 | } 25 | 26 | // Convert an array to an object using a provided key (src: https://medium.com/dailyjs/rewriting-javascript-converting-an-array-of-objects-to-an-object-ec579cafbfc7) 27 | export function arrayToObject(array: T[], keyField: keyof T): { [key: string]: T } { 28 | return array.reduce((obj: any, item: any) => { 29 | obj[item[keyField]] = item; 30 | return obj; 31 | }, {}); 32 | } 33 | -------------------------------------------------------------------------------- /src/components/NamedDropdown.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Dropdown, DropdownButton, InputGroup } from "react-bootstrap"; 3 | import { randomString } from "../logic/Utils"; 4 | 5 | interface IProps { 6 | available_values: string[]; 7 | selected_value: string; 8 | title: string; 9 | onSelect: (value: string) => void; 10 | className?: string; 11 | } 12 | 13 | const NamedDropdown: React.FunctionComponent = (props: IProps) => { 14 | const { available_values, selected_value, title, onSelect, className } = props; 15 | 16 | const [id] = useState("dropdown_" + randomString(16)); 17 | 18 | const onComponentAudioFeatureSelect = (audioFeature: string) => () => onSelect(audioFeature); 19 | 20 | return ( 21 | 22 | 23 | {title} 24 | 25 | 31 | {available_values.map((audio_feature) => ( 32 | 33 | {audio_feature} 34 | 35 | ))} 36 | 37 | 38 | ); 39 | }; 40 | 41 | export default NamedDropdown; 42 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | Emotionify 15 | 16 | 17 | 18 | 27 | 28 | 29 | 35 | 36 | 37 | 38 |
39 | 40 | 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "emotionify", 3 | "version": "0.1.0", 4 | "homepage": "https://emotionify.nitratine.net/", 5 | "private": true, 6 | "dependencies": { 7 | "@types/hookrouter": "^2.2.1", 8 | "@types/jest": "24.0.13", 9 | "@types/node": "15.6.1", 10 | "@types/react": "16.8.19", 11 | "@types/react-dom": "16.8.4", 12 | "@types/react-plotly.js": "^2.2.2", 13 | "array-flat-polyfill": "^1.0.1", 14 | "cogo-toast": "^3.1.0", 15 | "hookrouter": "^1.2.3", 16 | "plotly.js": "^1.48.3", 17 | "react": "^16.8.6", 18 | "react-bootstrap": "^1.0.0-beta.9", 19 | "react-dom": "^16.8.6", 20 | "react-metatags-hook": "^1.1.2", 21 | "react-plotly.js": "^2.3.0", 22 | "react-scripts": "3.0.1", 23 | "sitemap": "^7.1.1", 24 | "spotify-web-api-js": "^1.2.0", 25 | "ts-node": "^10.7.0", 26 | "typescript": "3.5.1" 27 | }, 28 | "scripts": { 29 | "start": "react-scripts start", 30 | "ts-node": "ts-node", 31 | "prebuild": "ts-node -O {\\\"module\\\":\\\"commonjs\\\"} tools/generate-sitemap.ts", 32 | "build": "react-scripts build", 33 | "postbuild": "ts-node -O {\\\"module\\\":\\\"commonjs\\\"} tools/copy-index.ts" 34 | }, 35 | "eslintConfig": { 36 | "extends": "react-app" 37 | }, 38 | "browserslist": { 39 | "production": [ 40 | ">0.2%", 41 | "not dead", 42 | "not op_mini all" 43 | ], 44 | "development": [ 45 | "last 1 chrome version", 46 | "last 1 firefox version", 47 | "last 1 safari version" 48 | ] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/pages/Sort/TrackSortControl.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import NamedDropdown from "../../components/NamedDropdown"; 3 | 4 | interface IProps { 5 | available_audio_features: string[]; 6 | available_track_sorting_methods: string[]; 7 | selected_x_axis: string; 8 | selected_y_axis: string; 9 | selected_sorting_method: string; 10 | onXAxisSelect: (selection: string) => void; 11 | onYAxisSelect: (selection: string) => void; 12 | onSortMethodSelect: (selection: string) => void; 13 | } 14 | 15 | const TrackSortControl: React.FunctionComponent = (props: IProps) => { 16 | const { 17 | available_audio_features, 18 | available_track_sorting_methods, 19 | selected_x_axis, 20 | selected_y_axis, 21 | selected_sorting_method 22 | } = props; 23 | const { onXAxisSelect, onYAxisSelect, onSortMethodSelect } = props; 24 | 25 | return ( 26 | <> 27 | 34 | 35 | 42 | 43 | 49 | 50 | ); 51 | }; 52 | 53 | export default TrackSortControl; 54 | -------------------------------------------------------------------------------- /src/components/SpotifyLoginStatusButton.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { navigate, usePath } from "hookrouter"; 3 | import { Button } from "react-bootstrap"; 4 | import { localStorageRedirectKey } from "../pages/SpotifyAuthorization"; 5 | import SpotifyLogoRound from "../img/spotify-logo-round.png"; 6 | 7 | interface IProps { 8 | user: SpotifyApi.CurrentUsersProfileResponse | undefined; 9 | onLoggedInClick?: () => void; 10 | } 11 | 12 | const SpotifyLoginStatusButton: React.FunctionComponent = (props: IProps) => { 13 | const { user, onLoggedInClick } = props; 14 | 15 | const path = usePath(); 16 | 17 | const loggedInStatusButtonClick = () => { 18 | if (user === undefined) { 19 | localStorage.setItem(localStorageRedirectKey, path); 20 | navigate("/spotify-authorization"); 21 | } else { 22 | if (onLoggedInClick) { 23 | onLoggedInClick(); 24 | } 25 | } 26 | }; 27 | 28 | return ( 29 | 46 | ); 47 | }; 48 | 49 | export default SpotifyLoginStatusButton; 50 | -------------------------------------------------------------------------------- /src/components/PlaylistDetails.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Badge, Spinner } from "react-bootstrap"; 3 | import { PlaylistObjectSimplifiedWithTrackIds } from "../models/Spotify"; 4 | 5 | interface IProps { 6 | playlists: PlaylistObjectSimplifiedWithTrackIds[]; 7 | tracksLoading: boolean; 8 | } 9 | 10 | const PlaylistDetails: React.FunctionComponent = (props: IProps) => { 11 | const { playlists, tracksLoading } = props; 12 | 13 | if (playlists.length === 1) { 14 | const playlist = playlists[0]; 15 | return ( 16 |
17 |

{playlist.name}

18 |
19 | 20 | {playlist.owner.display_name} 21 | 22 | 23 | Songs: {playlist.tracks.total} 24 | 25 | 26 | Spotify 27 | 28 | 29 | {playlist.public ? "Public" : "Private"} 30 | 31 |
32 | {tracksLoading && ( 33 |
34 | 35 |
36 | )} 37 |
38 | ); 39 | } else { 40 | return ( 41 |
42 |

43 | {playlists.length} Playlist{playlists.length > 1 && "s"} Selected 44 |

45 |
46 | 47 | Songs: {playlists.map((p) => p.tracks.total).reduce((a, b) => a + b)} 48 | 49 |
50 | {tracksLoading && ( 51 |
52 | 53 |
54 | )} 55 |
56 | ); 57 | } 58 | }; 59 | 60 | export default PlaylistDetails; 61 | -------------------------------------------------------------------------------- /src/components/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { navigate, usePath } from "hookrouter"; 3 | import banner from "../img/banner.png"; 4 | import { Container, Nav, Navbar } from "react-bootstrap"; 5 | import SpotifyLoginStatusButton from "./SpotifyLoginStatusButton"; 6 | import GithubLogo from "../img/github-logo.png"; 7 | 8 | interface IProps { 9 | user: SpotifyApi.CurrentUsersProfileResponse | undefined; 10 | onAuthButtonLoggedInClick: () => void; 11 | } 12 | 13 | const navbarLinks: { [key: string]: string } = { 14 | "/": "Home", 15 | "/sort": "Sort", 16 | "/compare": "Compare", 17 | "/tools": "Tools", 18 | "/about": "About" 19 | }; 20 | 21 | const Navigation: React.FunctionComponent = (props: IProps) => { 22 | const { user } = props; 23 | const { onAuthButtonLoggedInClick } = props; 24 | 25 | const currentPath = usePath(); 26 | 27 | const goTo = (location: string) => () => navigate(location); 28 | 29 | return ( 30 | 31 | 32 | 33 | Emotionify Banner Logo 40 | 41 | 42 | 43 | 50 | 53 | 54 | 55 | 56 | ); 57 | }; 58 | 59 | export default Navigation; 60 | -------------------------------------------------------------------------------- /src/components/StoredDataDialog.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Button, Modal } from "react-bootstrap"; 3 | import { 4 | Token, 5 | PlaylistObjectSimplifiedWithTrackIds, 6 | TrackWithAudioFeatures 7 | } from "../models/Spotify"; 8 | 9 | interface IProps { 10 | token: Token; 11 | user: SpotifyApi.UserObjectPrivate; 12 | playlists: { [key: string]: PlaylistObjectSimplifiedWithTrackIds }; 13 | tracks: { [key: string]: TrackWithAudioFeatures }; 14 | onClose: () => void; 15 | onLogOut: () => void; 16 | } 17 | 18 | const StoredDataDialog: React.FunctionComponent = (props: IProps) => { 19 | const { token, user, playlists, tracks } = props; 20 | const { onClose, onLogOut } = props; 21 | 22 | const tokenStub = 23 | token.value.substr(0, 10) + 24 | "....." + 25 | token.value.substr(token.value.length - 10, token.value.length); 26 | const publicPlaylistCount = Object.values(playlists).filter((p) => p.public).length; 27 | const privatePlaylistCount = Object.values(playlists).filter((p) => !p.public).length; 28 | const tracksStored = Object.values(tracks).length; 29 | 30 | return ( 31 | 32 | 33 | {user.display_name} 34 | 35 | 36 | Data currently stored: 37 |
    38 |
  • 39 | Token expires at: {token.expiry.toLocaleTimeString()} 40 |
  • 41 |
  • 42 | User associated: {user.display_name} 43 |
  • 44 |
  • 45 | Playlists stored:{" "} 46 | 47 | {publicPlaylistCount} public and {privatePlaylistCount} private 48 | 49 |
  • 50 |
  • 51 | Tracks stored (with audio features): {tracksStored} 52 |
  • 53 |
54 |
55 | 56 | 59 | 60 |
61 | ); 62 | }; 63 | 64 | export default StoredDataDialog; 65 | -------------------------------------------------------------------------------- /src/pages/Tools/FilterOrderByAudioFeature.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { DropdownButton, Dropdown, InputGroup } from "react-bootstrap"; 3 | import { FilterFunctionProps } from "./filter"; 4 | import { TrackWithAudioFeatures, availableTrackAudioFeatures } from "../../models/Spotify"; 5 | 6 | interface IProps extends FilterFunctionProps {} 7 | 8 | const FilterOrderByAudioFeature: React.FunctionComponent = (props: IProps) => { 9 | const { outputCallback } = props; 10 | 11 | const [feature, setFeature] = useState("Energy"); 12 | 13 | useEffect(() => { 14 | const audio_feature_key: keyof SpotifyApi.AudioFeaturesObject = 15 | availableTrackAudioFeatures[feature].key; 16 | outputCallback( 17 | (tracks: TrackWithAudioFeatures[]): TrackWithAudioFeatures[] => 18 | tracks.sort((a: TrackWithAudioFeatures, b: TrackWithAudioFeatures): number => { 19 | const a_audio_feature_value: number = 20 | a.audio_features === undefined || a.audio_features === null 21 | ? 0 22 | : (a.audio_features[audio_feature_key] as number); 23 | const b_audio_feature_value: number = 24 | b.audio_features === undefined || b.audio_features === null 25 | ? 0 26 | : (b.audio_features[audio_feature_key] as number); 27 | return b_audio_feature_value - a_audio_feature_value; 28 | }), 29 | `Order songs by ${feature}` 30 | ); 31 | }, [feature]); 32 | 33 | const setFeatureFromDropdown = (featureValue: string) => () => setFeature(featureValue); 34 | 35 | return ( 36 | <> 37 | 38 | 39 | Audio Feature 40 | 41 | 42 | {Object.keys(availableTrackAudioFeatures).map((af) => ( 43 | 44 | {af} 45 | 46 | ))} 47 | 48 | 49 | 50 | ); 51 | }; 52 | 53 | export default FilterOrderByAudioFeature; 54 | -------------------------------------------------------------------------------- /src/pages/Compare/RadarChartAudioFeatureComparison.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Plot from "react-plotly.js"; 3 | import { 4 | PlaylistObjectSimplifiedWithTrackIds, 5 | availableTrackAudioFeatures, 6 | TrackWithAudioFeatures 7 | } from "../../models/Spotify"; 8 | import { getSupportedTrackAudioFeaturesFromPlaylist } from "../../logic/Spotify"; 9 | 10 | interface IProps { 11 | playlists: PlaylistObjectSimplifiedWithTrackIds[]; 12 | tracks: { [key: string]: TrackWithAudioFeatures }; 13 | } 14 | 15 | const RadarChartAudioFeatureComparison: React.FunctionComponent = (props: IProps) => { 16 | const { playlists, tracks } = props; 17 | 18 | const availableTrackAudioFeatureNames = Object.keys(availableTrackAudioFeatures).filter( 19 | (af_name) => availableTrackAudioFeatures[af_name].show_in_compare_radar 20 | ); 21 | 22 | const calculateTrackAverageForAudioFeatures = ( 23 | playlist: PlaylistObjectSimplifiedWithTrackIds, 24 | audio_feature: keyof SpotifyApi.AudioFeaturesObject 25 | ): number => { 26 | const avaiableAudioFeatures = getSupportedTrackAudioFeaturesFromPlaylist(playlist, tracks); 27 | const audioFeatureValues = avaiableAudioFeatures.map((af) => af[audio_feature] as number); 28 | const average = 29 | audioFeatureValues.reduce((p: number, c: number) => p + c, 0) / audioFeatureValues.length; 30 | return average; 31 | }; 32 | 33 | return ( 34 | ({ 36 | type: "scatterpolar", 37 | r: availableTrackAudioFeatureNames.map((af_name) => 38 | calculateTrackAverageForAudioFeatures(playlist, availableTrackAudioFeatures[af_name].key) 39 | ), 40 | theta: availableTrackAudioFeatureNames, 41 | fill: "toself", 42 | name: playlist.name 43 | }))} 44 | layout={{ 45 | hovermode: "closest", 46 | margin: { t: 20 }, 47 | autosize: true, 48 | legend: { 49 | orientation: "h" 50 | } 51 | }} 52 | useResizeHandler={true} 53 | config={{ 54 | displayModeBar: false, 55 | responsive: true 56 | }} 57 | className="w-100 m-auto" 58 | style={{ 59 | maxWidth: 800, 60 | height: 400 61 | }} 62 | /> 63 | ); 64 | }; 65 | 66 | export default RadarChartAudioFeatureComparison; 67 | -------------------------------------------------------------------------------- /src/pages/Compare/BoxPlotAudioFeatureComparison.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Plot from "react-plotly.js"; 3 | import { PlaylistObjectSimplifiedWithTrackIds, TrackWithAudioFeatures } from "../../models/Spotify"; 4 | import { getSupportedTrackAudioFeaturesFromPlaylist } from "../../logic/Spotify"; 5 | 6 | const plotLimitExpand = 0.01; // To help get 0 and 1 grid lines 7 | 8 | interface IProps { 9 | selectedPlaylists: PlaylistObjectSimplifiedWithTrackIds[]; 10 | tracks: { [key: string]: TrackWithAudioFeatures }; 11 | audioFeature: keyof SpotifyApi.AudioFeaturesObject; 12 | min: number | undefined; 13 | max: number | undefined; 14 | } 15 | 16 | const BoxPlotAudioFeatureComparison: React.FunctionComponent = (props: IProps) => { 17 | const { selectedPlaylists, tracks, audioFeature, max, min } = props; 18 | 19 | return ( 20 | ({ 22 | x: getSupportedTrackAudioFeaturesFromPlaylist(playlist, tracks).map( 23 | (afs) => afs[audioFeature] 24 | ), 25 | type: "box", 26 | hoverinfo: "text", 27 | text: getSupportedTrackAudioFeaturesFromPlaylist(playlist, tracks).map( 28 | (af) => 29 | tracks[af.id].name + "
by " + tracks[af.id].artists.map((a) => a.name).join(", ") 30 | ), 31 | name: playlist.name 32 | }))} 33 | layout={{ 34 | hovermode: "closest", 35 | margin: { t: 0, b: 0, l: 0, r: 5 }, 36 | xaxis: { 37 | range: [ 38 | min !== undefined ? min - plotLimitExpand : undefined, 39 | max !== undefined ? max + plotLimitExpand : undefined 40 | ], 41 | showgrid: true, 42 | zeroline: false 43 | }, 44 | yaxis: { 45 | ticks: "", 46 | showticklabels: false 47 | }, 48 | autosize: true, 49 | legend: { 50 | orientation: "h" 51 | } 52 | }} 53 | useResizeHandler={true} 54 | config={{ 55 | displayModeBar: false, 56 | responsive: true 57 | }} 58 | className="w-100 m-auto" 59 | style={{ 60 | maxWidth: 800, 61 | height: 50 + selectedPlaylists.length * 60 62 | }} 63 | /> 64 | ); 65 | }; 66 | 67 | export default BoxPlotAudioFeatureComparison; 68 | -------------------------------------------------------------------------------- /src/pages/Tools/FilterAddPlaylists.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { Spinner } from "react-bootstrap"; 3 | import PlaylistSelection from "../../components/PlaylistSelection"; 4 | import { FilterFunctionProps } from "./filter"; 5 | import { PlaylistObjectSimplifiedWithTrackIds, TrackWithAudioFeatures } from "../../models/Spotify"; 6 | 7 | interface IProps extends FilterFunctionProps { 8 | playlists: { [key: string]: PlaylistObjectSimplifiedWithTrackIds }; 9 | tracks: { [key: string]: TrackWithAudioFeatures }; 10 | playlistsLoading: Set; 11 | refreshPlaylist: (playlist: SpotifyApi.PlaylistObjectSimplified) => void; 12 | } 13 | 14 | const FilterAddPlaylists: React.FunctionComponent = (props: IProps) => { 15 | const { playlists, tracks, playlistsLoading, refreshPlaylist, outputCallback } = props; 16 | 17 | const [selectedPlaylistIds, setSelectedPlaylistIds] = useState([]); 18 | 19 | useEffect(() => { 20 | // Pass up new function on change 21 | const selectedPlaylistTracks: TrackWithAudioFeatures[] = selectedPlaylistIds 22 | .map((pid) => playlists[pid].track_ids) 23 | .map((tids) => tids.map((tid) => tracks[tid])) 24 | .flat(); 25 | outputCallback( 26 | (tracks: TrackWithAudioFeatures[]): TrackWithAudioFeatures[] => [ 27 | ...tracks, 28 | ...selectedPlaylistTracks 29 | ], 30 | `${selectedPlaylistIds.length} Playlist${ 31 | selectedPlaylistIds.length !== 1 ? "s" : "" 32 | } Selected` 33 | ); 34 | }, [selectedPlaylistIds, playlists, tracks]); 35 | 36 | const onPlaylistSelectionChange = (playlist_ids: string[]) => { 37 | setSelectedPlaylistIds(playlist_ids); 38 | playlist_ids.forEach((playlist_id) => { 39 | if (playlists[playlist_id].track_ids.length === 0) { 40 | refreshPlaylist(playlists[playlist_id]); 41 | } 42 | }); 43 | }; 44 | 45 | return ( 46 | <> 47 | 54 | {playlistsLoading.size > 0 && ( 55 |
56 | 57 | Loading tracks for selected playlists 58 |
59 | )} 60 | 61 | ); 62 | }; 63 | 64 | export default FilterAddPlaylists; 65 | -------------------------------------------------------------------------------- /src/pages/Tools/TrackTable.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Accordion, Card, Table } from "react-bootstrap"; 3 | import { randomString } from "../../logic/Utils"; 4 | import { TrackWithAudioFeatures } from "../../models/Spotify"; 5 | 6 | interface IProps { 7 | tracks: TrackWithAudioFeatures[]; 8 | open: boolean; 9 | openToggle: () => void; 10 | } 11 | 12 | const header_cell_style: React.CSSProperties = { 13 | position: "sticky", 14 | top: 0, 15 | background: "white", 16 | borderTop: 0 17 | }; 18 | 19 | const TrackTable: React.FunctionComponent = (props: IProps) => { 20 | const { tracks, open, openToggle } = props; 21 | 22 | const [randomEventKey] = useState(randomString(16)); 23 | 24 | return ( 25 | 30 | 31 | 37 | {`Filtered Songs: ${tracks.length} (click to ${open ? "collapse" : "expand"})`} 38 | 39 | 40 | 44 | 45 | 46 | 47 | 48 | 49 | 52 | 53 | 54 | 55 | {tracks.map((track: TrackWithAudioFeatures, index: number) => ( 56 | 57 | 58 | 59 | 62 | 63 | ))} 64 | 65 |
PositionTitle 50 | Artist(s) 51 |
{index + 1}{track.name} 60 | {track.artists.map((a) => a.name).join(", ")} 61 |
66 |
67 |
68 |
69 |
70 | ); 71 | }; 72 | 73 | export default TrackTable; 74 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | Emotionify Banner 4 | 5 |
6 |

Create emotionally gradiented Spotify playlists and more.

7 |

🌐: emotionify.nitratine.net

8 | 9 | ## 🧪 Development Setup 10 | 11 | 1. Clone the repo 12 | 2. Execute `npm install` 13 | 3. Create a new app / client id at [developer.spotify.com](https://developer.spotify.com/dashboard/applications). 14 | 4. Copy your client id into `/src/config.ts`. 15 | 5. Click "Edit Settings" in the newly created Spotify developer app and add a redirect URI to where `/spotify-authorization` will be hosted - by default this will be `https://localhost:3000/spotify-authorization` 16 | 6. Set `HTTPS=true` (HTTPS is required to use `window.crypto`) 17 | - In PowerShell: `$env:HTTPS = "true"` 18 | 7. Set `NODE_OPTIONS=--openssl-legacy-provider` (react-scripts doesn't play nice with newer versions of Node) 19 | - In PowerShell: ` $env:NODE_OPTIONS = "--openssl-legacy-provider"` 20 | 8. Execute `npm start` 21 | 9. Accept the SSL warning 22 | 23 | ## 📷 Snippets From the Web App 24 | 25 | Example Sort Visualisation of a Personal Playlist 26 | ![Example Sort Visualisation of a Personal Playlist](https://nitratine.net/posts/emotionify/emotionify-sort-comparison.png) 27 | 28 | Example Comparison Visualisation of a Personal Playlists 29 | ![Example Comparison Visualisation of a Personal Playlists](https://nitratine.net/posts/emotionify/emotionify-compare-box-plot.png) 30 | 31 | Example of Applying Filters to Playlists 32 | ![Example of Applying Filters to Playlists](https://nitratine.net/posts/emotionify/emotionifytools-page-demo.png) 33 | 34 | ## 📝 Features 35 | 36 | - **Spotify authorization for library access** 37 | - **Sort a playlist by valence and energy** - Sorting on these two values can create a transition from sadder/slower songs to more happy/energetic songs. - Can change the sorting audio features and sorting method - Exports to a new playlist 38 | - **Compare playlists** - Compare multiple playlists in 1D, 2D or 7D from selected audio features. 39 | - **Playlist tools** - Add playlists and apply filters and functions to playlists to manipulate song ordering - Exports to a new playlist 40 | 41 | > All [audio features](https://developer.spotify.com/documentation/web-api/reference/tracks/get-audio-features/) used are pre-computed by Spotify and obtained through their API. 42 | 43 | ## ❓ Why? 44 | 45 | Emotionify is an application I had thought about for a few years after doing a project at university on attempting to detect emotion in music and portraying it in an interactive environment. 46 | 47 | I was curious how the method implemented would play out with music I listen to every day and wanted some extra tools for Spotify playlists. 48 | 49 | Emotionify is not 100% accurate as emotion is highly opinion based and the values used to sort songs are averages over the whole song. This tool however does give insight on how well a computer can plot an emotional gradient with a list of songs. 50 | -------------------------------------------------------------------------------- /src/models/Spotify.ts: -------------------------------------------------------------------------------- 1 | export interface SpotifyData { 2 | user: SpotifyApi.UserObjectPrivate | undefined; 3 | playlists: { 4 | [key: string]: PlaylistObjectSimplifiedWithTrackIds; 5 | }; 6 | tracks: { 7 | [key: string]: TrackWithAudioFeatures; 8 | }; 9 | } 10 | 11 | export interface Token { 12 | value: string; 13 | expiry: Date; 14 | } 15 | 16 | export interface PlaylistObjectSimplifiedWithTrackIds extends SpotifyApi.PlaylistObjectSimplified { 17 | track_ids: string[]; 18 | } 19 | 20 | export interface TrackWithAudioFeatures extends SpotifyApi.TrackObjectFull { 21 | audio_features: SpotifyApi.AudioFeaturesObject | null | undefined; // undefined when none have been requested, null when they don't exist (after requesting) 22 | } 23 | 24 | export interface AudioFeatureProperty { 25 | key: keyof SpotifyApi.AudioFeaturesObject; 26 | min: number | undefined; 27 | max: number | undefined; 28 | show_in_sort: boolean; 29 | show_in_compare_radar: boolean; 30 | } 31 | 32 | export const availableTrackAudioFeatures: { [key: string]: AudioFeatureProperty } = { 33 | Acousticness: { 34 | key: "acousticness", 35 | min: 0, 36 | max: 1, 37 | show_in_sort: true, 38 | show_in_compare_radar: true 39 | }, 40 | Danceability: { 41 | key: "danceability", 42 | min: 0, 43 | max: 1, 44 | show_in_sort: true, 45 | show_in_compare_radar: true 46 | }, 47 | Duration: { 48 | key: "duration_ms", 49 | min: 0, 50 | max: undefined, 51 | show_in_sort: true, 52 | show_in_compare_radar: false 53 | }, 54 | Energy: { 55 | key: "energy", 56 | min: 0, 57 | max: 1, 58 | show_in_sort: true, 59 | show_in_compare_radar: true 60 | }, 61 | Instrumentalness: { 62 | key: "instrumentalness", 63 | min: 0, 64 | max: 1, 65 | show_in_sort: true, 66 | show_in_compare_radar: true 67 | }, 68 | Key: { 69 | key: "key", 70 | min: undefined, 71 | max: undefined, 72 | show_in_sort: false, 73 | show_in_compare_radar: false 74 | }, 75 | Liveness: { 76 | key: "liveness", 77 | min: 0, 78 | max: 1, 79 | show_in_sort: true, 80 | show_in_compare_radar: true 81 | }, 82 | Loudness: { 83 | key: "loudness", 84 | min: undefined, 85 | max: 0, 86 | show_in_sort: true, 87 | show_in_compare_radar: false 88 | }, 89 | Mode: { 90 | key: "mode", 91 | min: 0, 92 | max: 1, 93 | show_in_sort: false, 94 | show_in_compare_radar: false 95 | }, 96 | Speechiness: { 97 | key: "speechiness", 98 | min: 0, 99 | max: 1, 100 | show_in_sort: true, 101 | show_in_compare_radar: true 102 | }, 103 | Tempo: { 104 | key: "tempo", 105 | min: undefined, 106 | max: undefined, 107 | show_in_sort: true, 108 | show_in_compare_radar: false 109 | }, 110 | "Time Signature": { 111 | key: "time_signature", 112 | min: 0, 113 | max: undefined, 114 | show_in_sort: false, 115 | show_in_compare_radar: false 116 | }, 117 | Valence: { 118 | key: "valence", 119 | min: 0, 120 | max: 1, 121 | show_in_sort: true, 122 | show_in_compare_radar: true 123 | } 124 | }; 125 | -------------------------------------------------------------------------------- /src/components/TokenRefreshWarning.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { navigate } from "hookrouter"; 3 | import { Button, Modal } from "react-bootstrap"; 4 | import { Token } from "../models/Spotify"; 5 | 6 | interface IProps { 7 | token: Token | undefined; 8 | onLogOut: () => void; 9 | } 10 | 11 | const warningMilliseconds = 5 * 60 * 1000; 12 | 13 | const TokenRefreshWarning: React.FunctionComponent = (props: IProps) => { 14 | const { token } = props; 15 | const { onLogOut } = props; 16 | 17 | const [open, setOpen] = useState(false); 18 | 19 | useEffect(() => { 20 | const timers: NodeJS.Timeout[] = []; 21 | if (token !== undefined) { 22 | const milliseconds_left = token.expiry.getTime() - new Date().getTime(); 23 | if (warningMilliseconds < milliseconds_left) { 24 | // If there is more than the warning time left 25 | timers.push( 26 | setTimeout(() => { 27 | // Setup warning 28 | setOpen(true); 29 | }, milliseconds_left - warningMilliseconds) 30 | ); 31 | timers.push( 32 | setTimeout(() => { 33 | // Setup token expired 34 | setOpen(true); 35 | }, milliseconds_left) 36 | ); 37 | } else if (milliseconds_left > 0) { 38 | // If there is time left 39 | timers.push( 40 | setTimeout(() => { 41 | // Setup token expired 42 | setOpen(true); 43 | }, milliseconds_left) 44 | ); 45 | setOpen(true); // Show 46 | } else { 47 | // If there is no time left 48 | setOpen(true); // Show 49 | } 50 | } 51 | 52 | return () => timers.forEach((t) => clearTimeout(t)); 53 | }, [token]); 54 | 55 | const refreshClick = () => { 56 | onLogOut(); 57 | navigate("/spotify-authorization"); 58 | }; 59 | const cancelClick = () => setOpen(false); 60 | 61 | if (token !== undefined && open) { 62 | const expired: boolean = token.expiry.getTime() - new Date().getTime() <= 0; 63 | 64 | return ( 65 | 66 | 67 | 68 | {expired ? "Spotify Token Expired" : "Spotify Token Refresh Warning"} 69 | 70 | 71 | 72 | {expired ? ( 73 | <> 74 | Your Spotify token has now expired and we can no longer access your data; sign back in 75 | with Spotify to get an new token. 76 |
77 | We will leave you logged in here so you can still view your data but we will not be 78 | able to get data from Spotify for you. 79 | 80 | ) : ( 81 | <> 82 | Since Spotify issues client side tokens for upto an hour, you will need a new token 83 | soon. Your current token expires at {token.expiry.toLocaleTimeString()}. 84 |
85 | To do this, we'll send you back to the Spotify authorization page again to get a new 86 | token. 87 | 88 | )} 89 |
90 | 91 | 94 | 97 | 98 |
99 | ); 100 | } else { 101 | return <>; 102 | } 103 | }; 104 | 105 | export default TokenRefreshWarning; 106 | -------------------------------------------------------------------------------- /src/pages/Sort/TrackTable.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Accordion, Card, Table } from "react-bootstrap"; 3 | import { SpotifyTrackWithIndexes } from "../../logic/PointSorting"; 4 | import { randomString } from "../../logic/Utils"; 5 | 6 | interface IProps { 7 | tracks: SpotifyTrackWithIndexes[]; // These are sorted using the current method when they come in 8 | x_audio_feature: keyof SpotifyApi.AudioFeaturesObject; 9 | x_audio_feature_name: string; 10 | y_audio_feature: keyof SpotifyApi.AudioFeaturesObject; 11 | y_audio_feature_name: string; 12 | } 13 | 14 | const header_cell_style: React.CSSProperties = { 15 | position: "sticky", 16 | top: 0, 17 | background: "white", 18 | borderTop: 0 19 | }; 20 | 21 | const expandedDefault = false; 22 | 23 | const TrackTable: React.FunctionComponent = (props: IProps) => { 24 | const { 25 | tracks, 26 | x_audio_feature, 27 | x_audio_feature_name, 28 | y_audio_feature, 29 | y_audio_feature_name 30 | } = props; 31 | 32 | const [randomEventKey] = useState(randomString(16)); 33 | const [expanded, setExpanded] = useState(expandedDefault); 34 | const toggleExpansion = () => setExpanded(!expanded); 35 | 36 | return ( 37 | 42 | 43 | 49 | {expanded 50 | ? "Songs in Playlist (click to collapse)" 51 | : "Songs in Playlist (click to expand)"} 52 | 53 | 54 | 58 | 59 | 60 | 61 | 62 | 63 | 66 | 67 | 68 | 69 | 70 | 71 | {tracks.map((track) => ( 72 | 73 | 84 | 85 | 88 | 93 | 98 | 99 | ))} 100 | 101 |
MovedTitle 64 | Artist(s) 65 | {x_audio_feature_name}{y_audio_feature_name}
82 | {track.index.before - track.index.after} 83 | {track.name} 86 | {track.artists.map((a) => a.name).join(", ")} 87 | 89 | {track.audio_features !== undefined && 90 | track.audio_features !== null && 91 | track.audio_features[x_audio_feature]} 92 | 94 | {track.audio_features !== undefined && 95 | track.audio_features !== null && 96 | track.audio_features[y_audio_feature]} 97 |
102 |
103 |
104 |
105 |
106 | ); 107 | }; 108 | 109 | export default TrackTable; 110 | -------------------------------------------------------------------------------- /src/pages/Home/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { navigate } from "hookrouter"; 3 | import { Button, Container } from "react-bootstrap"; 4 | import BannerImage from "../../img/banner.png"; 5 | import SortPageDemoImage from "../../img/sort-page-demo.png"; 6 | import ComparePageDemoImage from "../../img/compare-page-demo.png"; 7 | import ToolsPageDemoImage from "../../img/tools-page-demo.png"; 8 | 9 | const Home: React.FunctionComponent = () => { 10 | const goTo = (location: string) => () => navigate(location); 11 | 12 | return ( 13 | <> 14 |
15 | 16 |

Emotionify

17 | Emotionify Banner Logo 23 |

24 | Easily create emotionally gradiented Spotify playlists for smoother emotional 25 | transitions in your listening 26 |

27 |
28 |
29 | 30 |
31 | 32 |

Sort Your Playlist

33 | Emotionify Sort Comparison 39 |
40 |

41 | Using features calculated by Spotify for each song, sort your playlist on an emotional 42 | gradient. 43 |

44 |

45 | You can also change how and what your songs are sorted by to explore different methods 46 | of sorting playlists and discover new ways to listen to your playlists. 47 |

48 |
49 | 52 |
53 |
54 | 55 |
56 | 57 |

Compare Playlists

58 | Playlist Box Plot Comparison 64 |
65 |

66 | Compare your playlists based off audio features calculated by Spotify. 67 |

68 |

69 | Select any number of playlists and compare them in one or two dimensions for any audio 70 | feature or seven dimensions for specific audio features. 71 |

72 |
73 | 76 |
77 |
78 | 79 |
80 | 81 |

Playlist Tools

82 | Playlist Tools 88 |
89 |

90 | Merge, filter and sort your playlists to make a more focused playlist. 91 |

92 |

93 | Select playlists, filter and sort by audio features and even randomise your playlists 94 | to make a playlist focused for any occasion. 95 |

96 |
97 | 100 |
101 |
102 | 103 | ); 104 | }; 105 | 106 | export default Home; 107 | -------------------------------------------------------------------------------- /src/pages/Tools/FilterAudioFeaturePredicate.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { Dropdown, DropdownButton, FormControl } from "react-bootstrap"; 3 | import { FormControlProps } from "react-bootstrap/FormControl"; 4 | import { ReplaceProps, BsPrefixProps } from "react-bootstrap/helpers"; 5 | import { FilterFunctionProps } from "./filter"; 6 | import { TrackWithAudioFeatures, availableTrackAudioFeatures } from "../../models/Spotify"; 7 | 8 | interface IProps extends FilterFunctionProps {} 9 | 10 | const operators: { [key: string]: (track_value: number, user_value: number) => boolean } = { 11 | "==": (track_value, user_value) => track_value === user_value, 12 | ">": (track_value, user_value) => track_value > user_value, 13 | "<": (track_value, user_value) => track_value < user_value, 14 | ">=": (track_value, user_value) => track_value >= user_value, 15 | "<=": (track_value, user_value) => track_value <= user_value 16 | }; 17 | const xor = (a: boolean, b: boolean): boolean => (a && !b) || (!a && b); 18 | 19 | const FilterAudioFeaturePredicate: React.FunctionComponent = (props: IProps) => { 20 | const { outputCallback } = props; 21 | 22 | const [include, setInclude] = useState(true); 23 | const [feature, setFeature] = useState("Energy"); 24 | const [operator, setOperator] = useState(">"); 25 | const [value, setValue] = useState("0.5"); 26 | 27 | useEffect(() => { 28 | const value_as_number = parseFloat(value); 29 | if (isNaN(value_as_number)) { 30 | outputCallback(undefined, "Please specify a number to filter on"); 31 | } else { 32 | const audio_feature_key: keyof SpotifyApi.AudioFeaturesObject = 33 | availableTrackAudioFeatures[feature].key; 34 | outputCallback( 35 | (tracks: TrackWithAudioFeatures[]): TrackWithAudioFeatures[] => 36 | tracks.filter((track) => 37 | xor( 38 | track.audio_features !== undefined && 39 | track.audio_features !== null && 40 | operators[operator]( 41 | track.audio_features[audio_feature_key] as number, 42 | value_as_number 43 | ), 44 | !include 45 | ) 46 | ), 47 | `${include ? "Include" : "Exclude"} songs where ${feature} ${operator} ${value}` 48 | ); 49 | } 50 | }, [include, feature, operator, value]); 51 | 52 | const setIncludeFromDropdown = (includeValue: boolean) => () => setInclude(includeValue); 53 | const setFeatureFromDropdown = (featureValue: string) => () => setFeature(featureValue); 54 | const setOperatorFromDropdown = (operatorValue: string) => () => setOperator(operatorValue); 55 | const setvalueFromDropdown = ( 56 | event: React.FormEvent & FormControlProps>> 57 | ) => setValue(event.currentTarget.value === undefined ? "" : event.currentTarget.value); 58 | 59 | return ( 60 |
61 | 66 | Include 67 | Exclude 68 | 69 | 70 | 71 | {Object.keys(availableTrackAudioFeatures).map((af) => ( 72 | 73 | {af} 74 | 75 | ))} 76 | 77 | 78 | 79 | {Object.keys(operators).map((op) => ( 80 | 81 | {op} 82 | 83 | ))} 84 | 85 | 86 | 95 |
96 | ); 97 | }; 98 | 99 | export default FilterAudioFeaturePredicate; 100 | -------------------------------------------------------------------------------- /src/pages/SpotifyAuthorization/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { navigate } from "hookrouter"; 3 | import { Container, Spinner } from "react-bootstrap"; 4 | import { encodeData, randomString } from "../../logic/Utils"; 5 | import { Token } from "../../models/Spotify"; 6 | import config from "../../config"; 7 | 8 | // Follows Spotify's Authorization Code with PKCE Flow 9 | // https://developer.spotify.com/documentation/web-api/tutorials/code-pkce-flow 10 | 11 | const localStorageCodeVerifierKey = "spotify-auth-code-verifier"; 12 | export const localStorageRedirectKey = "auth-local-redirect"; 13 | 14 | interface IProps { 15 | onTokenChange: (newToken: Token | undefined) => void; 16 | } 17 | 18 | const SpotifyAuthorization: React.FunctionComponent = ({ onTokenChange }) => { 19 | const [message, setMessage] = useState(

Loading...

); 20 | 21 | useEffect(() => { 22 | const params = new URLSearchParams(window.location.search); 23 | const code = params.get("code"); 24 | const error = params.get("error"); 25 | const redirectUri = `${window.location.origin}${window.location.pathname}`; // Don't want this to include the code/error coming back (so no window.location.href) 26 | 27 | if (error) { 28 | setMessage(

Authorization failed: {error}

); 29 | return; 30 | } 31 | 32 | if (code === null) { 33 | // No code, initiate authorization request 34 | const codeVerifier = randomString(64); 35 | localStorage.setItem(localStorageCodeVerifierKey, codeVerifier); 36 | 37 | crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier)).then((buffer) => { 38 | const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(buffer))) 39 | .replace(/\+/g, "-") 40 | .replace(/\//g, "_") 41 | .replace(/=+$/, ""); 42 | 43 | // Redirect 44 | const authUrl = `https://accounts.spotify.com/authorize?${encodeData({ 45 | client_id: config.spotify.clientId, 46 | response_type: "code", 47 | redirect_uri: redirectUri, 48 | code_challenge_method: "S256", 49 | code_challenge: codeChallenge, 50 | scope: config.spotify.permissionScope 51 | })}`; 52 | window.location.href = authUrl; 53 | }); 54 | 55 | setMessage( 56 | <> 57 | 58 |

Redirecting to Spotify...

59 | 60 | ); 61 | } else { 62 | // Pull out the code verifier we stored previously 63 | const codeVerifier = localStorage.getItem(localStorageCodeVerifierKey); 64 | localStorage.removeItem(localStorageCodeVerifierKey); 65 | if (codeVerifier === null) { 66 | setMessage(

Missing code verifier

); 67 | return; 68 | } 69 | 70 | // Exchange code for access token (after authorization request comes back) 71 | fetch("https://accounts.spotify.com/api/token", { 72 | method: "POST", 73 | headers: { "Content-Type": "application/x-www-form-urlencoded" }, 74 | body: encodeData({ 75 | client_id: config.spotify.clientId, 76 | grant_type: "authorization_code", 77 | code, 78 | redirect_uri: redirectUri, 79 | code_verifier: codeVerifier 80 | }) 81 | }) 82 | .then((res) => res.json()) 83 | .then((data) => { 84 | if (data.access_token && data.expires_in) { 85 | // Store token 86 | const expiryDate = new Date(); 87 | expiryDate.setSeconds(expiryDate.getSeconds() + data.expires_in); 88 | onTokenChange({ value: data.access_token, expiry: expiryDate }); 89 | 90 | // Redirect back to where we came from in the application 91 | let local_redirect = localStorage.getItem(localStorageRedirectKey); 92 | if (local_redirect) { 93 | localStorage.removeItem(localStorageRedirectKey); 94 | navigate(local_redirect); 95 | } else { 96 | navigate("/"); 97 | } 98 | 99 | setMessage( 100 | <> 101 | 102 |

Authorization successful

103 | 104 | ); 105 | } else { 106 | setMessage(

Failed to retrieve access token

); 107 | } 108 | }) 109 | .catch(() => setMessage(

Error fetching token

)); 110 | } 111 | }, []); 112 | 113 | return ( 114 | 115 |

Spotify Authorization

116 | {message} 117 |
118 | ); 119 | }; 120 | 121 | export default SpotifyAuthorization; 122 | -------------------------------------------------------------------------------- /src/components/ExportPlaylistInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import cogoToast from "cogo-toast"; 3 | import { Button, Dropdown, DropdownButton, FormControl, InputGroup } from "react-bootstrap"; 4 | import { BsPrefixProps, ReplaceProps } from "react-bootstrap/helpers"; 5 | import { FormControlProps } from "react-bootstrap/FormControl"; 6 | 7 | interface IProps { 8 | onExport: (name: string, makePublic: boolean) => Promise; 9 | } 10 | 11 | export const Export: React.FunctionComponent = (props: IProps) => { 12 | const { onExport } = props; 13 | 14 | const [name, setName] = useState(""); 15 | const [makePublic, setMakePublic] = useState(false); 16 | const [nameInvalid, setNameInvalid] = useState(false); 17 | 18 | const onNameChange = ( 19 | e: React.FormEvent & FormControlProps>> 20 | ) => { 21 | if (e.currentTarget.value !== undefined) { 22 | setName(e.currentTarget.value); 23 | setNameInvalid(e.currentTarget.value === ""); 24 | } 25 | }; 26 | 27 | const onMakePublicSelect = (makePublic: boolean) => () => setMakePublic(makePublic); 28 | 29 | const onCreate = () => { 30 | if (name === "") { 31 | setNameInvalid(true); 32 | } else { 33 | onExport(name, makePublic).then((success) => { 34 | if (success) { 35 | setName(""); 36 | cogoToast.success( 37 | "Playlist has been created. You can go to Spotify to see your new playlist.", 38 | { 39 | position: "bottom-center", 40 | heading: "Playlist Created", 41 | hideAfter: 10, 42 | onClick: (hide: any) => hide() 43 | } 44 | ); 45 | } else { 46 | cogoToast.error( 47 | "Failed to create playlist. Make sure you are connected to the internet and that your token is valid.", 48 | { 49 | position: "bottom-center", 50 | heading: "Failed To Create Playlist", 51 | hideAfter: 10, 52 | onClick: (hide: any) => hide() 53 | } 54 | ); 55 | } 56 | }); 57 | } 58 | }; 59 | 60 | return ( 61 | <> 62 |

Create New Playlist

63 | 64 | 65 | Playlist Name 66 | 67 | 75 | 81 | Private 82 | Public 83 | 84 | 85 | 88 | 89 | 90 | 91 | 95 | 96 | Playlist Name 97 | 98 | 106 | 107 | 111 | 117 | Private 118 | Public 119 | 120 | 121 | 124 | 125 | 126 | 127 | ); 128 | }; 129 | 130 | export default Export; 131 | -------------------------------------------------------------------------------- /src/pages/Compare/ScatterPlotDualAudioFeatureComparison.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Plot from "react-plotly.js"; 3 | import { 4 | PlaylistObjectSimplifiedWithTrackIds, 5 | availableTrackAudioFeatures, 6 | TrackWithAudioFeatures 7 | } from "../../models/Spotify"; 8 | import { getSupportedTrackAudioFeaturesFromPlaylist } from "../../logic/Spotify"; 9 | 10 | const plotLimitExpand = 0.01; // To help get 0 and 1 grid lines 11 | 12 | interface IProps { 13 | playlists: PlaylistObjectSimplifiedWithTrackIds[]; 14 | tracks: { [key: string]: TrackWithAudioFeatures }; 15 | x_audio_feature_name: string; 16 | y_audio_feature_name: string; 17 | } 18 | 19 | const ScatterPlotDualAudioFeatureComparison: React.FunctionComponent = (props: IProps) => { 20 | const { playlists, tracks, x_audio_feature_name, y_audio_feature_name } = props; 21 | 22 | // Audio feature helpers 23 | const x_audio_feature = availableTrackAudioFeatures[x_audio_feature_name]; 24 | const y_audio_feature = availableTrackAudioFeatures[y_audio_feature_name]; 25 | 26 | // Audio feature objects that exist for the playlists provided 27 | const supportedTrackAudioFeaturesPerPlaylist: SpotifyApi.AudioFeaturesObject[][] = playlists.map( 28 | (playlist) => getSupportedTrackAudioFeaturesFromPlaylist(playlist, tracks) 29 | ); 30 | 31 | // Max and min points in the data 32 | const all_x_values: number[] = supportedTrackAudioFeaturesPerPlaylist 33 | .map((playlistAudioFeatures) => 34 | playlistAudioFeatures.map((af) => af[x_audio_feature.key] as number) 35 | ) 36 | .flat(); 37 | const all_y_values: number[] = supportedTrackAudioFeaturesPerPlaylist 38 | .map((playlistAudioFeatures) => 39 | playlistAudioFeatures.map((af) => af[y_audio_feature.key] as number) 40 | ) 41 | .flat(); 42 | const points_x_min: number = Math.min(...all_x_values); 43 | const points_y_min: number = Math.min(...all_y_values); 44 | const points_x_max: number = Math.max(...all_x_values); 45 | const points_y_max: number = Math.max(...all_y_values); 46 | 47 | // The min and max are passed in, but still take the points into account just incase there are values outside of the defined range 48 | const scale_x_min: number | undefined = 49 | x_audio_feature.min !== undefined ? Math.min(x_audio_feature.min, points_x_min) : undefined; 50 | const scale_x_max: number | undefined = 51 | x_audio_feature.max !== undefined ? Math.max(x_audio_feature.max, points_x_max) : undefined; 52 | const scale_y_min: number | undefined = 53 | y_audio_feature.min !== undefined ? Math.min(y_audio_feature.min, points_y_min) : undefined; 54 | const scale_y_max: number | undefined = 55 | y_audio_feature.max !== undefined ? Math.max(y_audio_feature.max, points_y_max) : undefined; 56 | 57 | return ( 58 | ({ 60 | x: supportedTrackAudioFeaturesPerPlaylist[index].map( 61 | (af) => af[x_audio_feature.key] as number 62 | ), 63 | y: supportedTrackAudioFeaturesPerPlaylist[index].map( 64 | (af) => af[y_audio_feature.key] as number 65 | ), 66 | text: supportedTrackAudioFeaturesPerPlaylist[index].map((af) => { 67 | const track = tracks[af.id]; 68 | return ( 69 | "Title: " + 70 | track.name + 71 | "
Artist: " + 72 | track.artists.map((a) => a.name).join(", ") + 73 | "
" + 74 | x_audio_feature_name + 75 | ": " + 76 | af[x_audio_feature.key] + 77 | "
" + 78 | y_audio_feature_name + 79 | ": " + 80 | af[y_audio_feature.key] 81 | ); 82 | }), 83 | hoverinfo: "text", 84 | mode: "markers", 85 | marker: { 86 | size: 7 87 | }, 88 | name: playlist.name 89 | }))} 90 | layout={{ 91 | hovermode: "closest", 92 | margin: { t: 0, b: 0, l: 0, r: 0 }, 93 | plot_bgcolor: "transparent", 94 | paper_bgcolor: "transparent", 95 | xaxis: { 96 | range: [ 97 | scale_x_min !== undefined ? scale_x_min - plotLimitExpand : undefined, 98 | scale_x_max !== undefined ? scale_x_max + plotLimitExpand : undefined 99 | ], 100 | zeroline: false 101 | }, 102 | yaxis: { 103 | range: [ 104 | scale_y_min !== undefined ? scale_y_min - plotLimitExpand : undefined, 105 | scale_y_max !== undefined ? scale_y_max + plotLimitExpand : undefined 106 | ], 107 | zeroline: false 108 | }, 109 | legend: { 110 | orientation: "h" 111 | } 112 | }} 113 | useResizeHandler={true} 114 | config={{ 115 | displayModeBar: false, 116 | responsive: true 117 | }} 118 | className="w-100 m-auto" 119 | style={{ 120 | maxWidth: 700, 121 | height: 450 122 | }} 123 | /> 124 | ); 125 | }; 126 | 127 | export default ScatterPlotDualAudioFeatureComparison; 128 | -------------------------------------------------------------------------------- /src/serviceWorker.ts: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === "localhost" || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === "[::1]" || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/) 19 | ); 20 | 21 | type Config = { 22 | onSuccess?: (registration: ServiceWorkerRegistration) => void; 23 | onUpdate?: (registration: ServiceWorkerRegistration) => void; 24 | }; 25 | 26 | export function register(config?: Config) { 27 | if (process.env.NODE_ENV === "production" && "serviceWorker" in navigator) { 28 | // The URL constructor is available in all browsers that support SW. 29 | const publicUrl = new URL( 30 | (process as { env: { [key: string]: string } }).env.PUBLIC_URL, 31 | window.location.href 32 | ); 33 | if (publicUrl.origin !== window.location.origin) { 34 | // Our service worker won't work if PUBLIC_URL is on a different origin 35 | // from what our page is served on. This might happen if a CDN is used to 36 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 37 | return; 38 | } 39 | 40 | window.addEventListener("load", () => { 41 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 42 | 43 | if (isLocalhost) { 44 | // This is running on localhost. Let's check if a service worker still exists or not. 45 | checkValidServiceWorker(swUrl, config); 46 | 47 | // Add some additional logging to localhost, pointing developers to the 48 | // service worker/PWA documentation. 49 | navigator.serviceWorker.ready.then(() => { 50 | console.log( 51 | "This web app is being served cache-first by a service " + 52 | "worker. To learn more, visit https://bit.ly/CRA-PWA" 53 | ); 54 | }); 55 | } else { 56 | // Is not localhost. Just register service worker 57 | registerValidSW(swUrl, config); 58 | } 59 | }); 60 | } 61 | } 62 | 63 | function registerValidSW(swUrl: string, config?: Config) { 64 | navigator.serviceWorker 65 | .register(swUrl) 66 | .then((registration) => { 67 | registration.onupdatefound = () => { 68 | const installingWorker = registration.installing; 69 | if (installingWorker == null) { 70 | return; 71 | } 72 | installingWorker.onstatechange = () => { 73 | if (installingWorker.state === "installed") { 74 | if (navigator.serviceWorker.controller) { 75 | // At this point, the updated precached content has been fetched, 76 | // but the previous service worker will still serve the older 77 | // content until all client tabs are closed. 78 | console.log( 79 | "New content is available and will be used when all " + 80 | "tabs for this page are closed. See https://bit.ly/CRA-PWA." 81 | ); 82 | 83 | // Execute callback 84 | if (config && config.onUpdate) { 85 | config.onUpdate(registration); 86 | } 87 | } else { 88 | // At this point, everything has been precached. 89 | // It's the perfect time to display a 90 | // "Content is cached for offline use." message. 91 | console.log("Content is cached for offline use."); 92 | 93 | // Execute callback 94 | if (config && config.onSuccess) { 95 | config.onSuccess(registration); 96 | } 97 | } 98 | } 99 | }; 100 | }; 101 | }) 102 | .catch((error) => { 103 | console.error("Error during service worker registration:", error); 104 | }); 105 | } 106 | 107 | function checkValidServiceWorker(swUrl: string, config?: Config) { 108 | // Check if the service worker can be found. If it can't reload the page. 109 | fetch(swUrl) 110 | .then((response) => { 111 | // Ensure service worker exists, and that we really are getting a JS file. 112 | const contentType = response.headers.get("content-type"); 113 | if ( 114 | response.status === 404 || 115 | (contentType != null && contentType.indexOf("javascript") === -1) 116 | ) { 117 | // No service worker found. Probably a different app. Reload the page. 118 | navigator.serviceWorker.ready.then((registration) => { 119 | registration.unregister().then(() => { 120 | window.location.reload(); 121 | }); 122 | }); 123 | } else { 124 | // Service worker found. Proceed as normal. 125 | registerValidSW(swUrl, config); 126 | } 127 | }) 128 | .catch(() => { 129 | console.log("No internet connection found. App is running in offline mode."); 130 | }); 131 | } 132 | 133 | export function unregister() { 134 | if ("serviceWorker" in navigator) { 135 | navigator.serviceWorker.ready.then((registration) => { 136 | registration.unregister(); 137 | }); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/logic/PointSorting.ts: -------------------------------------------------------------------------------- 1 | import { TrackWithAudioFeatures } from "../models/Spotify"; 2 | 3 | /* 4 | * Different methods of sorting x y points 5 | */ 6 | 7 | export interface SortablePoint { 8 | id: any; 9 | x: number; 10 | y: number; 11 | } 12 | 13 | export interface IndexedTrackId { 14 | // Minimal stored data 15 | id: string; 16 | index: { 17 | before: number; 18 | after: number; 19 | }; 20 | } 21 | 22 | export interface SpotifyTrackWithIndexes extends TrackWithAudioFeatures, IndexedTrackId {} // Not used here but relates to methods here 23 | 24 | export const availableSortingMethods: { [key: string]: Function } = { 25 | "Distance From Origin": originDistance, 26 | "Nearest Neighbour": nearestNeighbourFromOrigin, 27 | "X Axis": xAxis, 28 | "Y Axis": yAxis, 29 | "No Sorting": noSort 30 | }; 31 | 32 | function distanceToPoint(xOrigin: number, yOrigin: number, x: number, y: number): number { 33 | let a = xOrigin - x; 34 | let b = yOrigin - y; 35 | return Math.sqrt(a * a + b * b); 36 | } 37 | 38 | // Sort points based off their distance from the origin. Not great as it could jump from 1,0 to 0,1 39 | export function originDistance(points: SortablePoint[]): SortablePoint[] { 40 | let distances_from_origin = points.map((p) => { 41 | return { ...p, distance: distanceToPoint(0, 0, p.x, p.y) }; 42 | }); 43 | return distances_from_origin.sort((a, b) => a.distance - b.distance); 44 | } 45 | 46 | // Sorts points by going point to point based off the closest left-over points. Starts at 0,0. 47 | export function nearestNeighbourFromOrigin(points: SortablePoint[]): SortablePoint[] { 48 | if (points.length === 0) { 49 | return []; 50 | } 51 | 52 | let nearest_point_to_origin = points.reduce( 53 | (accumulator: SortablePoint, currentValue: SortablePoint): SortablePoint => { 54 | let acc_dist = distanceToPoint(0, 0, accumulator.x, accumulator.y); 55 | let curr_dist = distanceToPoint(0, 0, currentValue.x, currentValue.y); 56 | return acc_dist > curr_dist ? currentValue : accumulator; 57 | } 58 | ); 59 | 60 | let sorted_points: SortablePoint[] = [nearest_point_to_origin]; // Put the closest point to 0, 0 in the sorted list 61 | let points_left: SortablePoint[] = points.slice(); // Make a copy 62 | points_left = points_left.filter((p) => p.id !== nearest_point_to_origin.id); // Remove nearest point 63 | 64 | let current_point: SortablePoint = nearest_point_to_origin; 65 | const reduce_to_closest_point = ( 66 | accumulator: SortablePoint, 67 | currentValue: SortablePoint 68 | ): SortablePoint => { 69 | // Keep function out of the loop 70 | let acc_dist = distanceToPoint(current_point.x, current_point.y, accumulator.x, accumulator.y); 71 | let curr_dist = distanceToPoint( 72 | current_point.x, 73 | current_point.y, 74 | currentValue.x, 75 | currentValue.y 76 | ); 77 | return acc_dist > curr_dist ? currentValue : accumulator; 78 | }; 79 | while (points_left.length > 0) { 80 | let closest_point: SortablePoint = points_left.reduce(reduce_to_closest_point); 81 | sorted_points.push(closest_point); 82 | points_left = points_left.filter((p) => p.id !== closest_point.id); 83 | current_point = closest_point; 84 | } 85 | 86 | return sorted_points; 87 | } 88 | 89 | // Return the data provided 90 | export function noSort(points: SortablePoint[]): SortablePoint[] { 91 | return points; 92 | } 93 | 94 | // Sort by x 95 | export function xAxis(points: SortablePoint[]): SortablePoint[] { 96 | return points.sort((a, b) => { 97 | return a.x === b.x ? 0 : a.x > b.x ? 1 : -1; 98 | }); 99 | } 100 | 101 | // Sort by y 102 | export function yAxis(points: SortablePoint[]): SortablePoint[] { 103 | return points.sort((a, b) => { 104 | return a.y === b.y ? 0 : a.y > b.y ? 1 : -1; 105 | }); 106 | } 107 | 108 | // Sort tracks given x and y features and a sorting method 109 | export function sort( 110 | tracks: TrackWithAudioFeatures[], 111 | x_audio_feature: keyof SpotifyApi.AudioFeaturesObject, 112 | y_audio_feature: keyof SpotifyApi.AudioFeaturesObject, 113 | sorting_method: Function 114 | ): IndexedTrackId[] { 115 | // Get points initial indexes (to calculate movement) 116 | let tracks_with_playlist_indexes: IndexedTrackId[] = tracks.map((t, i) => { 117 | return { id: t.id, index: { before: i, after: 0 } }; 118 | }); 119 | 120 | // Convert tracks to sortable points 121 | let tracks_as_sp: SortablePoint[] = tracks.map((t) => { 122 | if (t.audio_features !== undefined && t.audio_features !== null) { 123 | let x = t.audio_features[x_audio_feature] as number; // We know better than the compiler 124 | let y = t.audio_features[y_audio_feature] as number; 125 | return { id: t.id, x: x, y: y }; 126 | } else { 127 | // Commonly occurs as t.audioFeatures === undefined on first playlist selection 128 | return { id: t.id, x: 0, y: 0 }; 129 | } 130 | }); 131 | 132 | // Sort the sortable points 133 | let tracks_as_sp_sorted: SortablePoint[] = sorting_method(tracks_as_sp); 134 | 135 | // Calculate new indexes using the sorted points 136 | let tracks_with_sorted_indexes: IndexedTrackId[] = tracks_as_sp_sorted 137 | .map((sp, i) => { 138 | let track = tracks_with_playlist_indexes.find((t) => t.id === sp.id); 139 | if (track !== undefined) { 140 | return { ...track, index: { before: track.index.before, after: i } }; 141 | } else { 142 | console.error("[TrackTable:tracks_with_sorted_indexes] Cannot find match for: " + sp.id); 143 | return null; 144 | } 145 | }) 146 | .filter((t: IndexedTrackId | null): t is IndexedTrackId => t !== null); 147 | 148 | // Quick debugging verification 149 | if (tracks_with_sorted_indexes.length !== tracks.length) { 150 | console.error("PointSorting.sort did not output the same amount of tracks input"); 151 | } 152 | 153 | // Sort tracks by the new indexes 154 | return tracks_with_sorted_indexes.sort((a, b) => a.index.after - b.index.after); 155 | } 156 | -------------------------------------------------------------------------------- /src/pages/Compare/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Container, Spinner } from "react-bootstrap"; 3 | import PlaylistSelection from "../../components/PlaylistSelection"; 4 | import SpotifyLoginStatusButton from "../../components/SpotifyLoginStatusButton"; 5 | import NamedDropdown from "../../components/NamedDropdown"; 6 | import BoxPlotAudioFeatureComparison from "./BoxPlotAudioFeatureComparison"; 7 | import ScatterPlotDualAudioFeatureComparison from "./ScatterPlotDualAudioFeatureComparison"; 8 | import RadarChartAudioFeatureComparison from "./RadarChartAudioFeatureComparison"; 9 | import { 10 | PlaylistObjectSimplifiedWithTrackIds, 11 | availableTrackAudioFeatures, 12 | TrackWithAudioFeatures 13 | } from "../../models/Spotify"; 14 | 15 | interface IProps { 16 | user: SpotifyApi.UserObjectPrivate | undefined; 17 | playlists: { [key: string]: PlaylistObjectSimplifiedWithTrackIds }; 18 | tracks: { [key: string]: TrackWithAudioFeatures }; 19 | playlistsLoading: Set; 20 | refreshPlaylist: (playlist: SpotifyApi.PlaylistObjectSimplified) => void; 21 | } 22 | 23 | const Compare: React.FunctionComponent = (props: IProps) => { 24 | const { user, playlists, tracks, playlistsLoading, refreshPlaylist } = props; 25 | 26 | const [selectedPlaylistIds, setSelectedPlaylistIds] = useState([]); 27 | const [oneDimensionComparisonAudioFeature, setOneDimensionComparisonAudioFeature] = useState( 28 | "Valence" 29 | ); 30 | const [twoDimensionComparisonAudioFeatureX, setTwoDimensionComparisonAudioFeatureX] = useState( 31 | "Valence" 32 | ); 33 | const [twoDimensionComparisonAudioFeatureY, setTwoDimensionComparisonAudioFeatureY] = useState( 34 | "Energy" 35 | ); 36 | 37 | const onPlaylistSelectionChange = (playlist_ids: string[]) => { 38 | setSelectedPlaylistIds(playlist_ids); 39 | playlist_ids.forEach((playlist_id) => { 40 | if (playlists[playlist_id].track_ids.length === 0) { 41 | refreshPlaylist(playlists[playlist_id]); 42 | } 43 | }); 44 | }; 45 | 46 | const selectedPlaylists = selectedPlaylistIds.map((pid) => playlists[pid]); 47 | 48 | const header = ( 49 | 50 |

Compare Playlists

51 |

52 | Select playlists and compare them on one audio feature, two audio features or seven 53 | pre-selected audio features. 54 |

55 |
56 | ); 57 | 58 | if (user === undefined) { 59 | return ( 60 | <> 61 | {header} 62 | 63 |

Sign into Spotify

64 |

65 | To get access to your playlists and the ability to create playlists, you need to sign 66 | into Spotify. 67 |

68 | 69 |
70 | 71 | ); 72 | } 73 | 74 | return ( 75 | <> 76 | {header} 77 | 78 | 79 |

Select a Playlist

80 | 86 | 87 | {playlistsLoading.size > 0 && ( 88 |
89 | 90 |
91 | )} 92 | 93 | {selectedPlaylistIds.length > 0 && ( 94 | <> 95 |
96 | 97 |
98 |

Single Audio Feature Comparison

99 | 100 | 107 | 108 | 115 |
116 | 117 |
118 |

Dual Audio Feature Comparison

119 | 120 |
121 | 128 | 129 | 135 |
136 | 137 | 143 |
144 | 145 |
146 |

0-1 Range Audio Feature Comparison

147 | 148 | 149 |
150 | 151 | )} 152 |
153 | 154 | ); 155 | }; 156 | 157 | export default Compare; 158 | -------------------------------------------------------------------------------- /src/components/PlaylistSelection.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Badge, Dropdown, DropdownButton, FormControl, InputGroup, Table } from "react-bootstrap"; 3 | import { PlaylistObjectSimplifiedWithTrackIds } from "../models/Spotify"; 4 | import useWindowSize from "../hooks/WindowSize"; 5 | 6 | interface IProps { 7 | playlists: PlaylistObjectSimplifiedWithTrackIds[]; 8 | selectedPlaylistIds: string[]; 9 | selectionsAllowed: "Single" | "Multiple" | "All"; 10 | defaultSelectionType?: "Single" | "Multiple"; 11 | onPlaylistSelectionChange: (playlist_ids: string[], scrollOnFirstSelection: boolean) => void; 12 | } 13 | 14 | const selectedBackground = 15 | "linear-gradient(to right, rgba(0, 82, 157, 0.3), rgba(235, 18, 27, 0.3))"; 16 | 17 | const PlaylistSelection: React.FunctionComponent = (props: IProps) => { 18 | const { 19 | playlists, 20 | selectedPlaylistIds, 21 | selectionsAllowed, 22 | defaultSelectionType, 23 | onPlaylistSelectionChange 24 | } = props; 25 | 26 | const [singlePlaylistSelection, setSinglePlaylistSelection] = useState( 27 | selectionsAllowed === "All" // If we are allowed all (two) selections 28 | ? defaultSelectionType !== undefined 29 | ? defaultSelectionType === "Single" 30 | : true // If a default is defined, use it, otherwise default to single 31 | : selectionsAllowed === "Single" // If a specific kind of selection is allowed, use that as the default (this will be fixed) 32 | ); 33 | const [search, setSearch] = useState(""); 34 | const windowSize = useWindowSize(); 35 | 36 | const onSearchChange = (event: React.FormEvent) => setSearch(event.currentTarget.value); 37 | const singlePlaylistSelectionChange = (value: boolean) => () => { 38 | if (value && selectedPlaylistIds.length > 1) { 39 | onPlaylistSelectionChange( 40 | selectedPlaylistIds.length > 0 ? [selectedPlaylistIds[0]] : [], 41 | false 42 | ); 43 | } 44 | setSinglePlaylistSelection(value); 45 | }; 46 | const onComponentPlaylistSelected = (playlist_id: string) => () => { 47 | if (singlePlaylistSelection || selectionsAllowed === "Single") { 48 | onPlaylistSelectionChange([playlist_id], true); 49 | } else { 50 | if (selectedPlaylistIds.indexOf(playlist_id) === -1) { 51 | onPlaylistSelectionChange([...selectedPlaylistIds, playlist_id], false); 52 | } else { 53 | onPlaylistSelectionChange( 54 | [...selectedPlaylistIds.filter((pid) => pid !== playlist_id)], 55 | false 56 | ); 57 | } 58 | } 59 | }; 60 | 61 | const filteredPlaylists = playlists.filter( 62 | (p) => p.name.toLowerCase().indexOf(search.toLowerCase()) !== -1 || p.uri.indexOf(search) !== -1 63 | ); 64 | const sortedPlaylists = filteredPlaylists.sort( 65 | (a: PlaylistObjectSimplifiedWithTrackIds, b: PlaylistObjectSimplifiedWithTrackIds) => 66 | a.name === b.name ? 0 : a.name > b.name ? 1 : -1 67 | ); 68 | 69 | const bootstrapBreakpointBiggerThanSm = () => windowSize.innerWidth > 576; // Bootstrap >sm in js 70 | 71 | return ( 72 | <> 73 |
74 | 75 | 76 | 77 | {bootstrapBreakpointBiggerThanSm() ? "Search Playlists" : "Search"} 78 | 79 | 80 | 81 | 89 | {(selectionsAllowed === "Single" || selectionsAllowed === "All") && ( 90 | 91 | Single Playlist Selection 92 | 93 | )} 94 | {(selectionsAllowed === "Multiple" || selectionsAllowed === "All") && ( 95 | 96 | Multiple Playlist Selection 97 | 98 | )} 99 | 100 | 101 | 102 |
103 | 104 | 105 | {sortedPlaylists.map((playlist) => ( 106 | 119 | 129 | 141 | 142 | ))} 143 | 144 |
120 | {/* Spotify has recently changed their responses - playlist.images is now nullable */} 121 | {playlist.images !== null && playlist.images.length > 0 && ( 122 | {"Artwork 127 | )} 128 | 130 |
{playlist.name}
131 |
132 | {playlist.owner.display_name} 133 | 134 | Songs: {playlist.tracks.total} 135 | 136 | 137 | {playlist.public ? "Public" : "Private"} 138 | 139 |
140 |
145 |
146 |
147 | 148 | ); 149 | }; 150 | 151 | export default PlaylistSelection; 152 | -------------------------------------------------------------------------------- /src/pages/Sort/PlotTracks.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Plot from "react-plotly.js"; 3 | import { Alert } from "react-bootstrap"; 4 | import { TrackWithAudioFeatures, availableTrackAudioFeatures } from "../../models/Spotify"; 5 | 6 | interface IProps { 7 | tracks: TrackWithAudioFeatures[]; 8 | x_audio_feature_name: string; 9 | y_audio_feature_name: string; 10 | } 11 | 12 | interface Point { 13 | x: number; 14 | y: number; 15 | } 16 | 17 | interface TrackPoint extends Point { 18 | track: { 19 | id: string; 20 | title: string; 21 | artist: string; 22 | length: number; 23 | }; 24 | } 25 | 26 | function getDistancePercentageAlongLineTheOfClosestPointOnLineToAnArbitaryPoint( 27 | start: Point, 28 | end: Point, 29 | point: Point 30 | ): number { 31 | // Modified from https://jsfiddle.net/soulwire/UA6H5/ 32 | let atob = { x: end.x - start.x, y: end.y - start.y }; 33 | let atop = { x: point.x - start.x, y: point.y - start.y }; 34 | let len = atob.x * atob.x + atob.y * atob.y; 35 | let dot = atop.x * atob.x + atop.y * atob.y; 36 | let t = Math.min(1, Math.max(0, dot / len)); 37 | return t; 38 | } 39 | 40 | function getPointAlongColourGradient( 41 | start_hex_colour: string, 42 | end_hex_colour: string, 43 | percentage: number 44 | ): string { 45 | const hex = (x: number): string => { 46 | let tmp = x.toString(16); 47 | return tmp.length === 1 ? "0" + tmp : tmp; 48 | }; 49 | 50 | var r = Math.ceil( 51 | parseInt(end_hex_colour.substring(0, 2), 16) * percentage + 52 | parseInt(start_hex_colour.substring(0, 2), 16) * (1 - percentage) 53 | ); 54 | var g = Math.ceil( 55 | parseInt(end_hex_colour.substring(2, 4), 16) * percentage + 56 | parseInt(start_hex_colour.substring(2, 4), 16) * (1 - percentage) 57 | ); 58 | var b = Math.ceil( 59 | parseInt(end_hex_colour.substring(4, 6), 16) * percentage + 60 | parseInt(start_hex_colour.substring(4, 6), 16) * (1 - percentage) 61 | ); 62 | return hex(r) + hex(g) + hex(b); 63 | } 64 | 65 | const PlotTracks: React.FunctionComponent = (props: IProps) => { 66 | const { tracks, x_audio_feature_name, y_audio_feature_name } = props; 67 | 68 | const x_audio_feature = availableTrackAudioFeatures[x_audio_feature_name]; 69 | const y_audio_feature = availableTrackAudioFeatures[y_audio_feature_name]; 70 | 71 | const points: TrackPoint[] = tracks 72 | .map((t) => { 73 | const track = { 74 | id: t.id, 75 | title: t.name, 76 | artist: t.artists.map((a) => a.name).join(", "), 77 | length: t.duration_ms 78 | }; 79 | 80 | if (t.audio_features !== undefined && t.audio_features !== null) { 81 | const x = t.audio_features[x_audio_feature.key] as number; 82 | const y = t.audio_features[y_audio_feature.key] as number; 83 | return { x: x, y: y, track: track }; 84 | } else if (t.audio_features === undefined) { 85 | // Commonly occurs as t.audio_features === undefined on first playlist selection 86 | return { x: 0, y: 0, track: track }; 87 | } else { 88 | // t.audio_features === null when no audio features could be found (ignore these then - we should not plot them) 89 | return null; 90 | } 91 | }) 92 | .filter((sp): sp is TrackPoint => sp !== null); 93 | 94 | // Max and min points in the data 95 | const points_x_min: number = Math.min(...points.map((p) => p.x)); 96 | const points_y_min: number = Math.min(...points.map((p) => p.y)); 97 | const points_x_max: number = Math.max(...points.map((p) => p.x)); 98 | const points_y_max: number = Math.max(...points.map((p) => p.y)); 99 | 100 | // Mix expected and actual min's and max's to defined the colour gradient 101 | const colour_x_min: number = 102 | x_audio_feature.min !== undefined ? Math.min(x_audio_feature.min, points_x_min) : points_x_min; 103 | const colour_x_max: number = 104 | x_audio_feature.max !== undefined ? Math.max(x_audio_feature.max, points_x_max) : points_x_max; 105 | const colour_y_min: number = 106 | y_audio_feature.min !== undefined ? Math.min(y_audio_feature.min, points_y_min) : points_y_min; 107 | const colour_y_max: number = 108 | y_audio_feature.max !== undefined ? Math.max(y_audio_feature.max, points_y_max) : points_y_max; 109 | 110 | // The min and max are passed in, but still take the points into account just incase there are values outside of the defined range 111 | const scale_x_min: number | undefined = 112 | x_audio_feature.min !== undefined ? Math.min(x_audio_feature.min, points_x_min) : undefined; 113 | const scale_x_max: number | undefined = 114 | x_audio_feature.max !== undefined ? Math.max(x_audio_feature.max, points_x_max) : undefined; 115 | const scale_y_min: number | undefined = 116 | y_audio_feature.min !== undefined ? Math.min(y_audio_feature.min, points_y_min) : undefined; 117 | const scale_y_max: number | undefined = 118 | y_audio_feature.max !== undefined ? Math.max(y_audio_feature.max, points_y_max) : undefined; 119 | 120 | return ( 121 | <> 122 | p.y), 126 | x: points.map((p) => p.x), 127 | text: points.map( 128 | (p) => 129 | "Title: " + 130 | p.track.title + 131 | "
Artist: " + 132 | p.track.artist + 133 | "
" + 134 | x_audio_feature_name + 135 | ": " + 136 | p.x + 137 | "
" + 138 | y_audio_feature_name + 139 | ": " + 140 | p.y 141 | ), 142 | hoverinfo: "text", 143 | mode: "lines+markers", 144 | marker: { 145 | size: 10, 146 | color: points.map((p) => { 147 | let distanceAlongGradient = getDistancePercentageAlongLineTheOfClosestPointOnLineToAnArbitaryPoint( 148 | { x: colour_x_min, y: colour_y_min }, 149 | { x: colour_x_max, y: colour_y_max }, 150 | { x: p.x, y: p.y } 151 | ); 152 | return "#" + getPointAlongColourGradient("00529d", "eb121b", distanceAlongGradient); 153 | }) 154 | }, 155 | line: { 156 | color: "rgba(44, 48, 51, 0.5)", 157 | width: 1 158 | } 159 | } 160 | ]} 161 | layout={{ 162 | hovermode: "closest", 163 | margin: { t: 0, b: 0, l: 0, r: 0 }, 164 | plot_bgcolor: "transparent", 165 | paper_bgcolor: "transparent", 166 | xaxis: { 167 | range: [scale_x_min, scale_x_max] 168 | }, 169 | yaxis: { 170 | range: [scale_y_min, scale_y_max] 171 | } 172 | }} 173 | useResizeHandler={true} 174 | config={{ 175 | displayModeBar: false, 176 | responsive: true 177 | }} 178 | className="w-100 m-auto overflow-hidden" 179 | style={{ 180 | maxWidth: 700, 181 | height: 450, 182 | border: "2px solid #6c757d", 183 | borderRadius: 10 184 | }} 185 | /> 186 | {tracks.filter((a) => a.audio_features === null).length > 0 && ( 187 | 188 | Warning: Some songs are missing audio features. 189 |
190 | Look in the table below to identify these songs (they will have no values beside them). 191 |
192 | )} 193 | 194 | ); 195 | }; 196 | 197 | export default PlotTracks; 198 | -------------------------------------------------------------------------------- /src/pages/About/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Container, Col, Row } from "react-bootstrap"; 3 | 4 | const About: React.FunctionComponent = () => { 5 | return ( 6 | 7 | 8 | 9 |

About

10 |

11 | Emotionify is an application I had thought about for a few years after doing a project 12 | at university on attempting to detect emotion in music and portraying it in an 13 | interactive environment. 14 |

15 |

16 | By default, choosing a playlist on the Sort page will sort your music by{" "} 17 | Valence and Energy which are{" "} 18 | 19 | audio features calculated by Spotify 20 | 21 | . When researching my project, this is was some of the best public and easily accessible 22 | data that related to emotion in music. Also with Spotify being so large and active among 23 | many people, it allows people to easily organise their own playlists. 24 |

25 |

26 | Emotionify is not 100% accurate as emotion is highly opinion based and the values used 27 | to sort songs are averages over the whole song. This tool however does give insight on 28 | how well a computer can plot an emotional gradient with a list of songs. 29 |

30 |

31 | I made this project because I was curious if this data really did mean anything and if 32 | my guess of the data being able to be used to sort playlists by emotion was correct. I 33 | can leave that up to you to decide! 34 |

35 |

36 | As you can see on the Sort page, I have also made the other audio features available for 37 | you to play around with. 38 |

39 |

40 | You can find other projects and tutorials made by me on my site{" "} 41 | nitratine.net. 42 |

43 | 44 |

FAQ

45 |
Why is this called Emotionify?
46 |

47 | By default, choosing a playlist on the Sort page will sort your music by{" "} 48 | Valence and Energy which I had used in a previous project to 49 | detect vague emotion of songs. 50 |

51 |
Will sorting a playlist remove duplicates?
52 |

53 | Yes, if the sort functionality didn't remove duplicate songs they would be bunched up by 54 | each other. Removing these allows for proper transitions into a different song. 55 |

56 |
Why do I have to log in so much?
57 |

58 | When you log in, a token is stored on your machine that's used to ask Spotify for your 59 | playlists (otherwise we couldn't see them). This token only lasts for an hour, meaning 60 | if you use this site for more than an hour at a time, you will need to authorise the 61 | application again. 62 |

63 |
What data do you store?
64 |

65 | All data received from Spotify is stored locally on your machine; none of this is sent 66 | off to some other server (apart from Spotify itself). You can view a summary of your 67 | stored data by clicking on your name in the top right when logged in. 68 |

69 |
How do I clear all my data?
70 |

71 | All data is stored in localStorage on your machine; simply logging out will clear all 72 | this data. 73 |

74 |
I just added a playlist on Spotify but it isn't showing up here
75 |

76 | We look for your playlists when you log in. If you want to use a playlist you 77 | created/added after you logged into this site, log out and then log back in to get the 78 | new playlist. 79 |

80 |
What are each of the audio features I select for the axis?
81 |

82 | Here are some summaries from{" "} 83 | 84 | Spotify 85 | 86 | : 87 |

88 |
    89 |
  • 90 | Acousticness: A confidence measure from 0 to 1 of whether the track is 91 | acoustic. 1 represents high confidence the track is acoustic. 92 |
  • 93 |
  • 94 | Danceability: Danceability describes how suitable a track is for dancing 95 | based on a combination of musical elements. A value of 0 is least danceable and 1 is 96 | most danceable. 97 |
  • 98 |
  • 99 | Duration: The duration of the track in milliseconds. 100 |
  • 101 |
  • 102 | Energy: Energy is a measure from 0 to 1 and represents a perceptual 103 | measure of intensity and activity. Typically, energetic tracks feel fast, loud, and 104 | noisy. For example, death metal has high energy, while a Bach prelude scores low on 105 | the scale. 106 |
  • 107 |
  • 108 | Instrumentalness: Predicts whether a track contains no vocals. The closer 109 | the instrumentalness value is to 1, the greater likelihood the track contains no vocal 110 | content. Values above 0.5 are intended to represent instrumental tracks, but 111 | confidence is higher as the value approaches 1. 112 |
  • 113 |
  • 114 | Key: The estimated overall key of the track. (-1 if no key is detected) 115 |
  • 116 |
  • 117 | Liveness: Detects the presence of an audience in the recording. Higher 118 | liveness values represent an increased probability that the track was performed live. 119 | A value above 0.8 provides strong likelihood that the track is live. 120 |
  • 121 |
  • 122 | Loudness: The overall loudness of a track in decibels (dB). Loudness 123 | values are averaged across the entire track and are useful for comparing relative 124 | loudness of tracks. Values typical range between -60 and 0 db. 125 |
  • 126 |
  • 127 | Mode: Mode indicates the modality (major or minor) of a track, the type 128 | of scale from which its melodic content is derived. Major is represented by 1 and 129 | minor is 0. 130 |
  • 131 |
  • 132 | Speechiness: Speechiness detects the presence of spoken words in a track. 133 | Talk shows and audio books are closer to 1, songs made entirely of spoken words are 134 | above 0.66, songs that contain both music and speech are typically around 0.33 - 0.66 135 | and values below 0.33 represent music and other non-speech-like tracks. 136 |
  • 137 |
  • 138 | Tempo: The overall estimated tempo of a track in beats per minute (BPM). 139 |
  • 140 |
  • 141 | Time Signature: An estimated overall time signature of a track. The time 142 | signature (meter) is a notational convention to specify how many beats are in each bar 143 | (or measure). 144 |
  • 145 |
  • 146 | Valence: A measure from 0 to 1 describing the musical positiveness 147 | conveyed by a track. Tracks with high valence sound more positive, while tracks with 148 | low valence sound more negative. 149 |
  • 150 |
151 | 152 |
153 |
154 | ); 155 | }; 156 | 157 | export default About; 158 | -------------------------------------------------------------------------------- /src/logic/Spotify.ts: -------------------------------------------------------------------------------- 1 | import SpotifyWebApi from "spotify-web-api-js"; 2 | import { chunkList } from "./Utils"; 3 | import { 4 | Token, 5 | PlaylistObjectSimplifiedWithTrackIds, 6 | TrackWithAudioFeatures 7 | } from "../models/Spotify"; 8 | 9 | const playlistRequestLimit = 20; 10 | const playlistTrackRequestLimit = 100; 11 | const trackFeaturesRequestLimit = 100; 12 | const maxRequestsSentAtOnce = 10; 13 | 14 | export interface OffsetLimit { 15 | offset: number; 16 | limit: number; 17 | } 18 | 19 | function offsetCalculation(limit: number, total: number): OffsetLimit[] { 20 | // Calculate request offsets needed to be performed 21 | let request_blocks: OffsetLimit[] = []; 22 | let accounted_for = 0; 23 | for (let i = 0; accounted_for < total; i++) { 24 | request_blocks.push({ offset: i * limit, limit: limit }); 25 | accounted_for += limit; 26 | } 27 | return request_blocks; 28 | } 29 | 30 | export function getAllSpotifyUsersPlaylists( 31 | token: Token, 32 | user: SpotifyApi.UserObjectPrivate 33 | ): Promise { 34 | // Gets all playlists for a user. Fast as it makes more than one request a time. 35 | return new Promise((resolve, reject) => { 36 | const spotifyApi = new SpotifyWebApi(); 37 | spotifyApi.setAccessToken(token.value); 38 | 39 | let playlists: SpotifyApi.PlaylistObjectSimplified[] = []; 40 | const offset = 0; 41 | const limit = playlistRequestLimit; 42 | 43 | let rejected = false; 44 | 45 | spotifyApi.getUserPlaylists(user.id, { offset, limit }).then( 46 | async (data) => { 47 | playlists = [...playlists, ...data.items]; // Store data from initial request 48 | 49 | // Calculate requests to be made and chunk them 50 | const request_blocks = offsetCalculation(limit, data.total).splice(1); // Ignore the first as we have already made that request 51 | const request_blocks_chunked = chunkList(request_blocks, maxRequestsSentAtOnce); 52 | 53 | for (let i = 0; i < request_blocks_chunked.length; i++) { 54 | // Start all requests in this chunk 55 | let promises: Promise[] = []; 56 | for (let j = 0; j < request_blocks_chunked[i].length; j++) { 57 | promises.push(spotifyApi.getUserPlaylists(user.id, request_blocks_chunked[i][j])); 58 | } 59 | // Wait for each request and get data 60 | await Promise.all(promises) 61 | .then((new_playlists) => { 62 | playlists = [...playlists, ...new_playlists.map((i) => i.items).flat()]; 63 | }) 64 | .catch((err) => { 65 | reject(err); 66 | rejected = true; 67 | }); 68 | if (rejected) { 69 | break; 70 | } 71 | } 72 | 73 | // Convert to PlaylistObjectSimplifiedWithTrackIds using a blank list 74 | resolve( 75 | playlists.map((p) => { 76 | return { ...p, track_ids: [] }; 77 | }) 78 | ); 79 | }, 80 | (err) => { 81 | reject(err); 82 | } 83 | ); 84 | }); 85 | } 86 | 87 | export function getAllTracksInPlaylist( 88 | token: Token, 89 | playlist: SpotifyApi.PlaylistObjectSimplified 90 | ): Promise { 91 | // Gets all tracks in a playlist. Fast as it makes more than one request a time. 92 | return new Promise((resolve, reject) => { 93 | const spotifyApi = new SpotifyWebApi(); 94 | spotifyApi.setAccessToken(token.value); 95 | 96 | let tracks: SpotifyApi.TrackObjectFull[] = []; 97 | const offset = 0; 98 | const limit = playlistTrackRequestLimit; 99 | 100 | let rejected = false; 101 | 102 | spotifyApi.getPlaylistTracks(playlist.id, { offset, limit }).then( 103 | async (data) => { 104 | tracks = [...tracks, ...data.items.map((i) => i.track)]; // Store data from initial request 105 | 106 | // Calculate requests to be made and chunk them 107 | const request_blocks = offsetCalculation(limit, data.total).splice(1); // Ignore the first as we have already made that request 108 | const request_blocks_chunked = chunkList(request_blocks, maxRequestsSentAtOnce); 109 | 110 | for (let i = 0; i < request_blocks_chunked.length; i++) { 111 | // Start all requests in this chunk 112 | let promises: Promise[] = []; 113 | for (let j = 0; j < request_blocks_chunked[i].length; j++) { 114 | promises.push(spotifyApi.getPlaylistTracks(playlist.id, request_blocks_chunked[i][j])); 115 | } 116 | // Wait for each request and get data 117 | await Promise.all(promises) 118 | .then((new_tracks) => { 119 | tracks = [ 120 | ...tracks, 121 | ...new_tracks 122 | .map((i) => i.items) 123 | .flat() 124 | .map((i) => i.track) 125 | ]; 126 | }) 127 | .catch((err) => { 128 | reject(err); 129 | rejected = true; 130 | }); 131 | if (rejected) { 132 | break; 133 | } 134 | } 135 | 136 | resolve( 137 | tracks.map((t) => { 138 | return { ...t, audio_features: undefined }; 139 | }) 140 | ); 141 | }, 142 | (err) => { 143 | reject(err); 144 | } 145 | ); 146 | }); 147 | } 148 | 149 | export function getAudioFeaturesForTracks( 150 | token: Token, 151 | track_ids: string[] 152 | ): Promise { 153 | // Gets all the audio features for a list of tracks. Fast as it makes more than one request a time. 154 | return new Promise(async (resolve, reject) => { 155 | const spotifyApi = new SpotifyWebApi(); 156 | spotifyApi.setAccessToken(token.value); 157 | 158 | let features: SpotifyApi.AudioFeaturesObject[] = []; 159 | const track_groups = chunkList(track_ids, trackFeaturesRequestLimit); // Tracks for each request 160 | const track_groups_chunked = chunkList(track_groups, maxRequestsSentAtOnce); // Batches of requests 161 | 162 | let rejected = false; 163 | 164 | for (let i = 0; i < track_groups_chunked.length; i++) { 165 | // Start all requests in this chunk 166 | let promises: Promise[] = []; 167 | for (let j = 0; j < track_groups_chunked[i].length; j++) { 168 | promises.push(spotifyApi.getAudioFeaturesForTracks(track_groups_chunked[i][j])); 169 | } 170 | // Wait for each request and get data 171 | await Promise.all(promises) 172 | .then((new_features) => { 173 | features = [...features, ...new_features.map((i) => i.audio_features).flat()]; 174 | }) 175 | .catch((err) => { 176 | reject(err); 177 | rejected = true; 178 | }); 179 | if (rejected) { 180 | break; 181 | } 182 | } 183 | 184 | // if (!rejected) { 185 | 186 | // } 187 | resolve(features); 188 | }); 189 | } 190 | 191 | export function createPlaylist( 192 | token: string, 193 | user: SpotifyApi.UserObjectPrivate, 194 | name: string, 195 | isPublic: boolean, 196 | track_uris: string[] 197 | ): Promise { 198 | return new Promise((resolve, reject) => { 199 | const spotifyApi = new SpotifyWebApi(); 200 | spotifyApi.setAccessToken(token); 201 | 202 | return spotifyApi 203 | .createPlaylist(user.id, { 204 | name: name, 205 | public: isPublic, 206 | description: "Created by emotionify.nitratine.net" 207 | }) 208 | .then( 209 | async (playlist) => { 210 | // Chunk into blocks of 100 211 | const chunks: string[][] = chunkList(track_uris, 100); 212 | 213 | // Add tracks in order 214 | for (let i = 0; i < chunks.length; i++) { 215 | await spotifyApi 216 | .addTracksToPlaylist(playlist.id, chunks[i]) 217 | .catch((err) => reject(err)); 218 | } 219 | 220 | // Manually set the amount of tracks rather than requesting for it again 221 | playlist.tracks.total = track_uris.length; 222 | resolve({ ...playlist, track_ids: [] }); 223 | }, 224 | (err) => { 225 | reject(err); 226 | } 227 | ); 228 | }); 229 | } 230 | 231 | // Get audio feature objects for tracks in a playlist that exist (aren't undefined for null) using all tracks requested 232 | export function getSupportedTrackAudioFeaturesFromPlaylist( 233 | playlist: PlaylistObjectSimplifiedWithTrackIds, 234 | tracks: { [key: string]: TrackWithAudioFeatures } 235 | ): SpotifyApi.AudioFeaturesObject[] { 236 | return playlist.track_ids // Get the playlists tracks 237 | .filter((tid) => tid in tracks) // Make sure the track exists 238 | .map((tid) => tracks[tid].audio_features) // Get all audio features 239 | .filter((af): af is SpotifyApi.AudioFeaturesObject => af !== undefined && af !== null); // Filter out invalid audio features 240 | } 241 | -------------------------------------------------------------------------------- /src/pages/Tools/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import cogoToast from "cogo-toast"; 3 | import { 4 | Accordion, 5 | Button, 6 | Card, 7 | Container, 8 | Dropdown, 9 | DropdownButton, 10 | InputGroup 11 | } from "react-bootstrap"; 12 | import FilterAddPlaylists from "./FilterAddPlaylists"; 13 | import FilterReverse from "./FilterReverse"; 14 | import FilterRandomise from "./FilterRandomise"; 15 | import FilterAudioFeaturePredicate from "./FilterAudioFeaturePredicate"; 16 | import FilterOrderByAudioFeature from "./FilterOrderByAudioFeature"; 17 | import FilterDistinct from "./FilterDistinct"; 18 | import SpotifyLoginStatusButton from "../../components/SpotifyLoginStatusButton"; 19 | import TrackTable from "./TrackTable"; 20 | import ExportPlaylistInput from "../../components/ExportPlaylistInput"; 21 | import { 22 | Token, 23 | PlaylistObjectSimplifiedWithTrackIds, 24 | TrackWithAudioFeatures 25 | } from "../../models/Spotify"; 26 | import { createPlaylist } from "../../logic/Spotify"; 27 | 28 | interface IProps { 29 | token: Token | undefined; 30 | user: SpotifyApi.UserObjectPrivate | undefined; 31 | playlists: { [key: string]: PlaylistObjectSimplifiedWithTrackIds }; 32 | tracks: { [key: string]: TrackWithAudioFeatures }; 33 | playlistsLoading: Set; 34 | refreshPlaylist: (playlist: SpotifyApi.PlaylistObjectSimplified) => void; 35 | refreshUsersPlaylists: (hard: boolean) => void; 36 | } 37 | 38 | interface AppliedFilter { 39 | filterName: string; 40 | filter: ((tracks: TrackWithAudioFeatures[]) => TrackWithAudioFeatures[]) | undefined; 41 | titleText: string; 42 | } 43 | 44 | const filters: { [key: string]: React.FunctionComponent } = { 45 | "Add Playlist": FilterAddPlaylists, 46 | Reverse: FilterReverse, 47 | Randomise: FilterRandomise, 48 | "Filter Audio Feature": FilterAudioFeaturePredicate, 49 | "Order by Audio Feature": FilterOrderByAudioFeature, 50 | "Remove Duplicates": FilterDistinct 51 | }; 52 | 53 | const track_identity_function = (tracks: TrackWithAudioFeatures[]): TrackWithAudioFeatures[] => 54 | tracks; 55 | 56 | const Tools: React.FunctionComponent = (props: IProps) => { 57 | const { 58 | token, 59 | user, 60 | playlists, 61 | tracks, 62 | playlistsLoading, 63 | refreshPlaylist, 64 | refreshUsersPlaylists 65 | } = props; 66 | 67 | const [appliedFilters, setAppliedFilters] = useState([ 68 | { 69 | filterName: "Add Playlist", 70 | filter: track_identity_function, 71 | titleText: "" 72 | } 73 | ]); 74 | const [addFilterDropdownSelection, setAddFilterDropdownSelection] = useState( 75 | Object.keys(filters)[0] 76 | ); 77 | const [activeCardEventKey, setActiveCardEventKey] = useState("0"); // Need to keep track of these as dropdowns in the accordion will close cards 78 | const [filteredTracks, setFilteredTracks] = useState([]); 79 | 80 | // Track table open state (need to track this in here otherwise it will close on every change) 81 | const [trackTableOpen, setTrackTableOpen] = useState(false); 82 | const trackTableOpenToggle = () => setTrackTableOpen(!trackTableOpen); 83 | 84 | useEffect(() => { 85 | // Apply new filters as they appear 86 | const currentFilters = appliedFilters.map((af) => af.filter); 87 | if (currentFilters.indexOf(undefined) === -1) { 88 | setFilteredTracks( 89 | appliedFilters 90 | .map((af) => af.filter as (tracks: TrackWithAudioFeatures[]) => TrackWithAudioFeatures[]) 91 | .reduce((accumulator: TrackWithAudioFeatures[], filter) => filter(accumulator), []) 92 | ); 93 | } else { 94 | setFilteredTracks([]); 95 | } 96 | }, [appliedFilters]); 97 | 98 | const header = ( 99 | 100 |

Playlist Tools

101 |

102 | Apply filters and functions to manipulate your playlists. 103 |

104 |
105 | ); 106 | 107 | if (user === undefined) { 108 | return ( 109 | <> 110 | {header} 111 | 112 |

Sign into Spotify

113 |

114 | To get access to your playlists and the ability to create playlists, you need to sign 115 | into Spotify. 116 |

117 | 118 |
119 | 120 | ); 121 | } 122 | 123 | const onCardHeaderClick = (eventKey: string) => () => 124 | setActiveCardEventKey(activeCardEventKey !== eventKey ? eventKey : ""); 125 | const filterDropdownSelect = (filterName: string) => () => 126 | setAddFilterDropdownSelection(filterName); 127 | const addFilter = () => { 128 | setActiveCardEventKey(appliedFilters.length + ""); 129 | setAppliedFilters((currentlyAppliedFilters) => [ 130 | ...currentlyAppliedFilters, 131 | { filterName: addFilterDropdownSelection, filter: undefined, titleText: "" } 132 | ]); 133 | }; 134 | const removeFilter = (index: number) => ( 135 | event: React.MouseEvent 136 | ) => { 137 | setAppliedFilters((currentlyAppliedFilters) => { 138 | let newListOfFeatures = [...currentlyAppliedFilters]; 139 | newListOfFeatures.splice(index, 1); 140 | return newListOfFeatures; 141 | }); 142 | event.stopPropagation(); 143 | }; 144 | 145 | const filterComponentOutputCallback = (index: number) => ( 146 | filter: ((tracks: TrackWithAudioFeatures[]) => TrackWithAudioFeatures[]) | undefined, 147 | titleText: string 148 | ) => { 149 | setAppliedFilters((currentlyAppliedFilters) => { 150 | let newListOfFeatures = [...currentlyAppliedFilters]; 151 | newListOfFeatures[index] = { 152 | filterName: newListOfFeatures[index].filterName, 153 | filter: filter, 154 | titleText: titleText 155 | }; 156 | return newListOfFeatures; 157 | }); 158 | }; 159 | 160 | const onExport = (name: string, isPublic: boolean): Promise => { 161 | return new Promise(async (resolve, reject) => { 162 | if (filteredTracks.length === 0) { 163 | cogoToast.warn("No songs present after filtering. Will not create an empty playlist.", { 164 | position: "bottom-center", 165 | heading: "No Songs", 166 | hideAfter: 10, 167 | onClick: (hide: any) => hide() 168 | }); 169 | reject(); 170 | return; 171 | } 172 | 173 | if (token !== undefined && user !== undefined) { 174 | // Map the sorted tracks to uris 175 | let track_uris: string[] = filteredTracks.map((t) => tracks[t.id].uri); 176 | // Create the playlist 177 | let success: boolean = await createPlaylist( 178 | token.value, 179 | user, 180 | name, 181 | isPublic, 182 | track_uris 183 | ).then( 184 | (playlist) => { 185 | refreshUsersPlaylists(false); // Get the new playlist by refreshing the playlist list (keep current track ids to not loose plot data) 186 | return true; 187 | }, 188 | (err) => { 189 | console.error(err); 190 | return false; 191 | } 192 | ); 193 | resolve(success); 194 | } 195 | resolve(false); 196 | }); 197 | }; 198 | 199 | return ( 200 | <> 201 | {header} 202 | 203 | 204 | 205 | {appliedFilters.map((appliedFilter: AppliedFilter, index: number) => { 206 | let FilterComponent = filters[appliedFilter.filterName]; 207 | return ( 208 | 209 | 214 | 217 | {appliedFilter.titleText} 218 | {index !== 0 && ( 219 | 222 | )} 223 | 224 | 225 | 226 | 233 | 234 | 235 | 236 | ); 237 | })} 238 | 239 | 240 |
241 | 242 | 243 | Add filter 244 | 245 | 251 | {Object.keys(filters) 252 | .sort() 253 | .map((filterName) => ( 254 | 255 | {filterName} 256 | 257 | ))} 258 | 259 | 260 | 261 | 262 | 263 |
264 | 265 |
266 | 271 |
272 | 273 |
274 | 275 |
276 |
277 | 278 | ); 279 | }; 280 | 281 | export default Tools; 282 | -------------------------------------------------------------------------------- /src/pages/Sort/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useRef } from "react"; 2 | import { Alert, Container } from "react-bootstrap"; 3 | import { 4 | availableSortingMethods, 5 | IndexedTrackId, 6 | sort, 7 | SpotifyTrackWithIndexes 8 | } from "../../logic/PointSorting"; 9 | import { createPlaylist } from "../../logic/Spotify"; 10 | import { 11 | PlaylistObjectSimplifiedWithTrackIds, 12 | availableTrackAudioFeatures, 13 | TrackWithAudioFeatures 14 | } from "../../models/Spotify"; 15 | import { Token } from "../../models/Spotify"; 16 | import PlaylistSelection from "../../components/PlaylistSelection"; 17 | import PlaylistDetails from "../../components/PlaylistDetails"; 18 | import PlotTracks from "./PlotTracks"; 19 | import TrackTable from "./TrackTable"; 20 | import TrackSortControl from "./TrackSortControl"; 21 | import ExportPlaylistInput from "../../components/ExportPlaylistInput"; 22 | import SpotifyLoginStatusButton from "../../components/SpotifyLoginStatusButton"; 23 | 24 | interface IProps { 25 | token: Token | undefined; 26 | user: SpotifyApi.UserObjectPrivate | undefined; 27 | playlists: { [key: string]: PlaylistObjectSimplifiedWithTrackIds }; 28 | tracks: { [key: string]: TrackWithAudioFeatures }; 29 | playlistsLoading: Set; 30 | refreshPlaylist: (playlist: SpotifyApi.PlaylistObjectSimplified) => void; 31 | refreshUsersPlaylists: (hard: boolean) => void; 32 | } 33 | 34 | interface selectedAxis { 35 | x: string; 36 | y: string; 37 | } 38 | 39 | export const Sort: React.FunctionComponent = (props: IProps) => { 40 | const { token, user, playlists, tracks, playlistsLoading } = props; 41 | const { refreshPlaylist, refreshUsersPlaylists } = props; 42 | 43 | const [selectedPlaylistIds, setSelectedPlaylistIds] = useState([]); 44 | const [selectedAxis, setSelectedAxis] = useState({ x: "Valence", y: "Energy" }); 45 | const [sortingMethod, setSortingMethod] = useState("Distance From Origin"); 46 | const [sortedTrackIds, setSortedTrackIds] = useState([]); 47 | const [firstPlaylistSelection, setFirstPlaylistSelection] = useState(true); 48 | const playlistDetailsWrapperNode = useRef(null); 49 | 50 | const onPlaylistSelectionChange = ( 51 | playlist_ids: string[], 52 | scrollOnFirstSelection: boolean = false 53 | ) => { 54 | setSelectedPlaylistIds(playlist_ids); 55 | playlist_ids.forEach((playlist_id) => { 56 | if (playlists[playlist_id].track_ids.length === 0) { 57 | refreshPlaylist(playlists[playlist_id]); 58 | } 59 | }); 60 | // Scroll on first selection 61 | if (scrollOnFirstSelection && firstPlaylistSelection) { 62 | setTimeout(() => { 63 | if (playlistDetailsWrapperNode.current !== null) { 64 | window.scroll({ 65 | top: 66 | playlistDetailsWrapperNode.current.getBoundingClientRect().top + window.scrollY - 50, 67 | behavior: "smooth" 68 | }); 69 | } 70 | }, 300); // Wait for elements below to appear 71 | } 72 | setFirstPlaylistSelection(false); 73 | }; 74 | const onXAxisSelect = (selection: string) => setSelectedAxis({ ...selectedAxis, x: selection }); 75 | const onYAxisSelect = (selection: string) => setSelectedAxis({ ...selectedAxis, y: selection }); 76 | const onSortMethodSelect = (selection: string) => setSortingMethod(selection); 77 | 78 | useEffect(() => { 79 | if (selectedPlaylistIds.length > 0) { 80 | const selected_playlist_track_ids: string[] = selectedPlaylistIds 81 | .map((pid) => (pid in playlists ? playlists[pid].track_ids : [])) 82 | .flat(); 83 | const selected_playlist_tracks: TrackWithAudioFeatures[] = Object.values(tracks) 84 | .filter((t) => selected_playlist_track_ids.indexOf(t.id) !== -1) 85 | .sort((a: TrackWithAudioFeatures, b: TrackWithAudioFeatures): number => { 86 | // Do a sort to put them in the correct order again (fixes incorrect order due to overlapping playlists) 87 | const aIndex = selected_playlist_track_ids.indexOf(a.id); 88 | const bIndex = selected_playlist_track_ids.indexOf(b.id); 89 | return aIndex === bIndex ? 0 : aIndex > bIndex ? 1 : -1; 90 | }); 91 | setSortedTrackIds( 92 | sort( 93 | selected_playlist_tracks, 94 | availableTrackAudioFeatures[selectedAxis.x].key, 95 | availableTrackAudioFeatures[selectedAxis.y].key, 96 | availableSortingMethods[sortingMethod] 97 | ) 98 | ); 99 | } else { 100 | setSortedTrackIds([]); 101 | } 102 | }, [selectedPlaylistIds, selectedAxis, sortingMethod, playlists, tracks]); 103 | 104 | const sortedTrackIdsThatExist = sortedTrackIds.filter((t) => tracks[t.id] !== undefined); // Need to check if the tracks currently exist (some of these track id's don't match to tracks when selecting different playlists quickly) 105 | const sorted_tracks: TrackWithAudioFeatures[] = sortedTrackIdsThatExist.map((t) => tracks[t.id]); 106 | const sorted_tracks_with_indexes: SpotifyTrackWithIndexes[] = sortedTrackIdsThatExist.map( 107 | (it) => { 108 | return { ...tracks[it.id], ...it }; 109 | } 110 | ); 111 | 112 | const onExport = (name: string, isPublic: boolean): Promise => { 113 | return new Promise(async (resolve, reject) => { 114 | if (token !== undefined && sortedTrackIds !== undefined && user !== undefined) { 115 | // Map the sorted tracks to uris 116 | let track_uris: string[] = sortedTrackIds.map((st) => tracks[st.id].uri); 117 | // Create the playlist 118 | let success: boolean = await createPlaylist( 119 | token.value, 120 | user, 121 | name, 122 | isPublic, 123 | track_uris 124 | ).then( 125 | (playlist) => { 126 | refreshUsersPlaylists(false); // Get the new playlist by refreshing the playlist list (keep current track ids to not loose plot data) 127 | return true; 128 | }, 129 | (err) => { 130 | console.error(err); 131 | return false; 132 | } 133 | ); 134 | resolve(success); 135 | } 136 | resolve(false); 137 | }); 138 | }; 139 | 140 | const header = ( 141 | 142 |

Playlist Sorting

143 |

144 | Here you can select a playlist and look at how the new playlist is sorted. You can then 145 | create the new playlist or select a different playlist. 146 |

147 |
148 | ); 149 | 150 | if (token === undefined) { 151 | return ( 152 | <> 153 | {header} 154 | 155 |

Sign into Spotify

156 |

157 | To get access to your playlists and the ability to create playlists, you need to sign 158 | into Spotify. 159 |

160 | 161 |
162 | 163 | ); 164 | } 165 | 166 | return ( 167 | <> 168 | {header} 169 | 170 |

Select a Playlist

171 | 178 | 179 | {selectedPlaylistIds.length > 0 && ( 180 | <> 181 |
182 | 183 |
184 | (pid in playlists ? playlists[pid] : null)) 187 | .filter( 188 | ( 189 | p: PlaylistObjectSimplifiedWithTrackIds | null 190 | ): p is PlaylistObjectSimplifiedWithTrackIds => p !== null 191 | )} 192 | tracksLoading={selectedPlaylistIds 193 | .map((pid) => playlistsLoading.has(pid)) 194 | .reduce((a, b) => a || b)} 195 | /> 196 |
197 | 198 |
199 | 204 |
205 | 206 |
207 | 210 | availableTrackAudioFeatures[audio_feature_name].show_in_sort 211 | )} 212 | available_track_sorting_methods={Object.keys(availableSortingMethods)} 213 | selected_x_axis={selectedAxis.x} 214 | selected_y_axis={selectedAxis.y} 215 | selected_sorting_method={sortingMethod} 216 | onXAxisSelect={onXAxisSelect} 217 | onYAxisSelect={onYAxisSelect} 218 | onSortMethodSelect={onSortMethodSelect} 219 | /> 220 |
221 | 222 | {selectedPlaylistIds 223 | .map((pid) => (pid in playlists ? playlists[pid].track_ids.length : 0)) 224 | .reduce((a, b) => a + b) > 0 && 225 | selectedPlaylistIds 226 | .map((pid) => (pid in playlists ? playlists[pid].tracks.total : 0)) 227 | .reduce((a, b) => a + b) !== sortedTrackIds.length && ( 228 | 229 | Warning: Duplicate songs will be removed in the new playlist 230 | 231 | )} 232 | 233 |
234 | 241 |
242 | 243 |
244 | 245 |
246 | 247 | )} 248 |
249 | 250 | ); 251 | }; 252 | 253 | export default Sort; 254 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import { useRoutes, useRedirect } from "hookrouter"; 3 | import SpotifyWebApi from "spotify-web-api-js"; 4 | import cogoToast from "cogo-toast"; 5 | import Navigation from "./components/Navigation"; 6 | import Footer from "./components/Footer"; 7 | import TokenRefreshWarning from "./components/TokenRefreshWarning"; 8 | import StoredDataDialog from "./components/StoredDataDialog"; 9 | import MetaTags from "./components/MetaTags"; 10 | import SpotifyAuthorization from "./pages/SpotifyAuthorization"; 11 | import Home from "./pages/Home"; 12 | import Sort from "./pages/Sort"; 13 | import Compare from "./pages/Compare"; 14 | import Tools from "./pages/Tools"; 15 | import About from "./pages/About"; 16 | import NotFound from "./pages/NotFound"; 17 | import useNavigatorOnline from "./hooks/NavigatorOnline"; 18 | import useScrollToTopOnRouteChange from "./hooks/ScrollToTopOnRouteChange"; 19 | import { 20 | Token, 21 | SpotifyData, 22 | PlaylistObjectSimplifiedWithTrackIds, 23 | TrackWithAudioFeatures 24 | } from "./models/Spotify"; 25 | import { 26 | getAllSpotifyUsersPlaylists, 27 | getAllTracksInPlaylist, 28 | getAudioFeaturesForTracks 29 | } from "./logic/Spotify"; 30 | import { arrayToObject } from "./logic/Utils"; 31 | 32 | const localStorageKey = "emotionify-app"; 33 | const storageVersion = 1; 34 | 35 | const emptySpotifyData = { 36 | user: undefined, 37 | playlists: {}, 38 | tracks: {}, 39 | audioFeatures: {} 40 | }; 41 | 42 | interface IStorage { 43 | version: number; 44 | token: Token; 45 | user: SpotifyApi.UserObjectPrivate | undefined; 46 | playlists: { [key: string]: PlaylistObjectSimplifiedWithTrackIds }; 47 | } 48 | 49 | export const App: React.FunctionComponent = () => { 50 | const [token, setToken] = useState(undefined); 51 | const [spotifyData, setSpotifyData] = useState(emptySpotifyData); 52 | const [storedDataDialogOpen, setStoredDataDialogOpen] = useState(false); 53 | const [playlistsLoading, setPlaylistsLoading] = useState>(new Set()); 54 | const isOnline = useNavigatorOnline(); 55 | useScrollToTopOnRouteChange(); 56 | 57 | const onTokenChange = (newToken: Token | undefined) => setToken(newToken); 58 | const onLogOut = () => onTokenChange(undefined); 59 | const openStoredDataDialog = () => setStoredDataDialogOpen(true); 60 | const closeStoredDataDialog = () => setStoredDataDialogOpen(false); 61 | 62 | const refreshUsersPlaylists = (hard: boolean = true) => { 63 | if (token !== undefined && spotifyData.user !== undefined) { 64 | getAllSpotifyUsersPlaylists(token, spotifyData.user) 65 | .then((playlists) => { 66 | // Remove all requested playlist track ids if we are refreshing hard 67 | setSpotifyData((prevState) => ({ 68 | ...prevState, 69 | playlists: { 70 | ...arrayToObject( 71 | playlists.map((p) => 72 | p.id in prevState.playlists && !hard 73 | ? { ...p, track_ids: prevState.playlists[p.id].track_ids } 74 | : p 75 | ), 76 | "id" 77 | ) 78 | } 79 | })); 80 | }) 81 | .catch((err) => 82 | cogoToast.error( 83 | "Could not get your playlists. Make sure you are connected to the internet and that your token is valid.", 84 | { 85 | position: "bottom-center", 86 | heading: "Error When Fetching Playlists", 87 | hideAfter: 20, 88 | onClick: (hide: any) => hide() 89 | } 90 | ) 91 | ); 92 | } 93 | }; 94 | 95 | const refreshPlaylist = (playlist: SpotifyApi.PlaylistObjectSimplified) => { 96 | if (token !== undefined && !playlistsLoading.has(playlist.id)) { 97 | setPlaylistsLoading((prevState) => new Set([...Array.from(prevState), playlist.id])); 98 | getAllTracksInPlaylist(token, playlist) 99 | .then((tracks) => { 100 | setSpotifyData((prevState) => { 101 | const tracks_with_data = tracks.filter((t) => Object.values(t).length !== 1); // Filter out tracks that don't have data (can happen with vidoes - will only be {audio_features: undefined}) 102 | if (tracks.length !== tracks_with_data.length) { 103 | cogoToast.warn( 104 | `Could not get data for ${tracks.length - tracks_with_data.length} song(s) from "${ 105 | playlist.name 106 | }". These are most likely videos in the playlist which are not supported.`, 107 | { 108 | position: "bottom-center", 109 | heading: "Possible Missing Songs", 110 | hideAfter: 20, 111 | onClick: (hide: any) => hide() 112 | } 113 | ); 114 | } 115 | const new_tracks = tracks_with_data.filter((t) => !(t.id in prevState.tracks)); 116 | return { 117 | ...prevState, 118 | tracks: { 119 | ...prevState.tracks, 120 | ...arrayToObject(new_tracks, "id") 121 | }, 122 | playlists: { 123 | ...prevState.playlists, 124 | [playlist.id]: { 125 | ...prevState.playlists[playlist.id], 126 | track_ids: tracks.map((t) => t.id) 127 | } 128 | } 129 | }; 130 | }); 131 | }) 132 | .catch((err) => 133 | cogoToast.error( 134 | `Could not get songs for the playlist "${playlist.name}". Make sure you are connected to the internet and that your token is valid.`, 135 | { 136 | position: "bottom-center", 137 | heading: "Error When Fetching Playlist's Songs", 138 | hideAfter: 20, 139 | onClick: (hide: any) => hide() 140 | } 141 | ) 142 | ) 143 | .finally(() => 144 | setPlaylistsLoading((prevState) => { 145 | const updatedPlaylistsLoading = new Set(prevState); 146 | updatedPlaylistsLoading.delete(playlist.id); 147 | return updatedPlaylistsLoading; 148 | }) 149 | ); 150 | } 151 | }; 152 | 153 | useEffect(() => { 154 | // Retrieve part of state from localStorage on startup 155 | let stored_data: string | null = localStorage.getItem(localStorageKey); 156 | if (stored_data !== null) { 157 | try { 158 | const stored_data_parsed: IStorage = JSON.parse(stored_data); 159 | stored_data_parsed.token.expiry = new Date(stored_data_parsed.token.expiry); 160 | if ( 161 | stored_data_parsed.version === storageVersion && 162 | stored_data_parsed.token.expiry > new Date() 163 | ) { 164 | setToken(stored_data_parsed.token); 165 | setSpotifyData({ 166 | ...emptySpotifyData, 167 | user: stored_data_parsed.user, 168 | playlists: stored_data_parsed.playlists 169 | }); 170 | refreshUsersPlaylists(); 171 | } 172 | } catch (error) { 173 | console.error("Failed to read state from localStorage"); 174 | } 175 | } 176 | }, []); 177 | 178 | useEffect(() => { 179 | // Store part of state in localStorage 180 | if (token !== undefined) { 181 | let data_to_store: IStorage = { 182 | version: storageVersion, 183 | token: token, 184 | user: spotifyData.user, 185 | playlists: arrayToObject( 186 | Object.values(spotifyData.playlists).map((p) => { 187 | return { ...p, track_ids: [] }; 188 | }), 189 | "id" 190 | ) // Empty track_id lists in playlist objects 191 | }; 192 | localStorage.setItem(localStorageKey, JSON.stringify(data_to_store)); 193 | } else { 194 | localStorage.removeItem(localStorageKey); 195 | } 196 | }, [token, spotifyData.user, spotifyData.playlists]); 197 | 198 | useEffect(() => { 199 | // Request the user when the token changes 200 | if (token === undefined) { 201 | setSpotifyData((prevState) => ({ ...prevState, user: undefined })); 202 | } else { 203 | const spotifyApi = new SpotifyWebApi(); 204 | spotifyApi.setAccessToken(token.value); 205 | spotifyApi 206 | .getMe() 207 | .then((user) => { 208 | if (spotifyData.user === undefined) { 209 | // If there is currently no user, clear the playlists and put the new user in 210 | setSpotifyData((prevState) => ({ ...prevState, playlists: {}, user: user })); 211 | } else if (spotifyData.user.id !== user.id) { 212 | // If this is a new user 213 | setSpotifyData((prevState) => ({ ...prevState, playlists: {}, user: user })); 214 | } else { 215 | // Same user, new token 216 | setSpotifyData((prevState) => ({ ...prevState, user: user })); 217 | } 218 | }) 219 | .catch((err) => 220 | cogoToast.error( 221 | "Could not get your profile. Make sure you are connected to the internet and that your token is valid.", 222 | { 223 | position: "bottom-center", 224 | heading: "Error When Fetching Your Profile", 225 | hideAfter: 20, 226 | onClick: (hide: any) => hide() 227 | } 228 | ) 229 | ); 230 | } 231 | }, [token]); 232 | 233 | useEffect(() => { 234 | // Request playlists on user change 235 | if (spotifyData.user === undefined) { 236 | setSpotifyData((prevState) => ({ ...prevState, playlists: {} })); 237 | } else { 238 | refreshUsersPlaylists(); 239 | } 240 | }, [spotifyData.user]); 241 | 242 | useEffect(() => { 243 | // Request audio features when needed 244 | const track_ids_with_no_audio_features: string[] = Object.values(spotifyData.tracks) 245 | .filter((t) => t.audio_features === undefined) 246 | .map((t) => t.id); 247 | 248 | if (token !== undefined && track_ids_with_no_audio_features.length > 0) { 249 | getAudioFeaturesForTracks(token, track_ids_with_no_audio_features) 250 | .then((audio_features: (SpotifyApi.AudioFeaturesObject | null)[]) => { 251 | // Some tracks will return null audio features 252 | // Check if any tracks do not have audio features 253 | const audio_features_by_track_id = arrayToObject( 254 | audio_features.filter((af): af is SpotifyApi.AudioFeaturesObject => af !== null), 255 | "id" 256 | ); 257 | const tracks_with_new_audio_features: TrackWithAudioFeatures[] = track_ids_with_no_audio_features.map( 258 | (tid) => ({ 259 | ...spotifyData.tracks[tid], 260 | audio_features: 261 | tid in audio_features_by_track_id ? audio_features_by_track_id[tid] : null 262 | }) 263 | ); 264 | 265 | // Show a warning if there were tracks with no audio features 266 | const null_audio_feature_tracks = tracks_with_new_audio_features.filter( 267 | (t) => t.audio_features === null 268 | ); 269 | if (null_audio_feature_tracks.length > 0) { 270 | console.warn( 271 | `Some audio features are null: ${null_audio_feature_tracks 272 | .map((t) => t.id) 273 | .join(", ")}` 274 | ); 275 | } 276 | 277 | setSpotifyData((prevState) => ({ 278 | ...prevState, 279 | tracks: { 280 | ...prevState.tracks, 281 | ...arrayToObject(tracks_with_new_audio_features, "id") 282 | } 283 | })); 284 | }) 285 | .catch((err) => 286 | cogoToast.error( 287 | "Could not get audio features for some songs. Make sure you are connected to the internet and that your token is valid.", 288 | { 289 | position: "bottom-center", 290 | heading: "Error When Fetching Song Audio Features", 291 | hideAfter: 20, 292 | onClick: (hide: any) => hide() 293 | } 294 | ) 295 | ); 296 | } 297 | }, [spotifyData.tracks]); 298 | 299 | useEffect(() => { 300 | // Display a warning when offline 301 | if (!isOnline) { 302 | cogoToast.warn( 303 | "You are now offline. You will not be able to request data from Spotify unless you are connected to the internet.", 304 | { 305 | position: "bottom-center", 306 | heading: "Offline", 307 | hideAfter: 10, 308 | onClick: (hide: any) => hide() 309 | } 310 | ); 311 | } 312 | }, [isOnline]); 313 | 314 | const routes = { 315 | "/": () => ( 316 | 320 | 321 | 322 | ), 323 | "/spotify-authorization": () => , 324 | "/spotify-authorization/": () => , 325 | "/sort": () => ( 326 | 331 | 340 | 341 | ), 342 | "/compare": () => ( 343 | 348 | 355 | 356 | ), 357 | "/tools": () => ( 358 | 363 | 372 | 373 | ), 374 | "/about": () => ( 375 | 380 | 381 | 382 | ) 383 | }; 384 | const routeResult = useRoutes(routes); 385 | useRedirect("/sort/", "/sort"); 386 | useRedirect("/compare/", "/compare"); 387 | useRedirect("/tools/", "/tools"); 388 | useRedirect("/about/", "/about"); 389 | 390 | return ( 391 | <> 392 | 393 | {token !== undefined && spotifyData.user !== undefined && storedDataDialogOpen && ( 394 | 402 | )} 403 | 404 | {routeResult || } 405 |