import React, { useEffect, useRef, useState } from 'react'; import axios from 'axios'; import Vibrant from 'node-vibrant'; import { Row, Col, Collapse, Typography, Affix, Tag, message, Space } from 'antd'; import ReactGA from 'react-ga'; import ParametersMenu from './ParametersMenu'; import SavePlaylist from './SavePlaylist'; import PlaylistSuccessPage from './PlaylistSuccessPage'; import Navbar from './Navbar'; import ErrorScreen from './ErrorScreen'; import SongList from './SongList'; import SearchSeeds from './SearchSeeds'; import { authenticate, getRecommendations, getArtists, getTracks } from '../modules/Spotify.js'; import Cookies from 'js-cookie'; import { Redirect } from 'react-router-dom'; const { Panel } = Collapse; const { Title } = Typography; const transport = axios.create({ withCredentials: true, }); /** * Check if results state stored in localstorage */ const checkStateStored = () => { return ( localStorage.getItem('songs') && localStorage.getItem('playlist') && localStorage.getItem('name') && localStorage.getItem('count') && localStorage.getItem('popularity') && localStorage.getItem('danceability') && localStorage.getItem('energy') && localStorage.getItem('acousticness') && localStorage.getItem('valence') && localStorage.getItem('tempo') && localStorage.getItem('seeds') && localStorage.getItem('seedColors') ); }; export default function Results(props) { const [accessToken] = useState(Cookies.get('access_token')); const [songs, setSongs] = useState([]); const [playlist, setPlaylist] = useState(null); const [loading, setLoading] = useState(true); const [name, setName] = useState('remixr'); const [generatedPlaylistLink, setGeneratedPlaylistLink] = useState(); const [error, setError] = useState(false); const initialFetchComplete = useRef(false); // Parameters const [count, setCount] = useState(25); const [popularity, setPopularity] = useState({ min: 0, max: 100 }); const [danceability, setDanceability] = useState({ min: 0, max: 1 }); const [energy, setEnergy] = useState({ min: 0, max: 1 }); const [acousticness, setAcousticness] = useState({ min: 0, max: 1 }); const [valence, setValence] = useState({ min: 0, max: 1 }); const [tempo, setTempo] = useState({ min: 50, max: 200 }); const [seeds, setSeeds] = useState(); const [seedColors, setSeedColors] = useState({}); /** * Save state to localstorage before redirecting to login page. Used for maintaining the same playlist items after being logged in */ const saveStateAndLogin = () => { localStorage.setItem('songs', JSON.stringify(songs)); localStorage.setItem('playlist', JSON.stringify(playlist)); localStorage.setItem('name', JSON.stringify(name)); localStorage.setItem('count', JSON.stringify(count)); localStorage.setItem('popularity', JSON.stringify(popularity)); localStorage.setItem('danceability', JSON.stringify(danceability)); localStorage.setItem('energy', JSON.stringify(energy)); localStorage.setItem('acousticness', JSON.stringify(acousticness)); localStorage.setItem('valence', JSON.stringify(valence)); localStorage.setItem('tempo', JSON.stringify(tempo)); localStorage.setItem('seeds', JSON.stringify(seeds)); localStorage.setItem('seedColors', JSON.stringify(seedColors)); const URI = process.env.REACT_APP_API_URL; window.location = `${URI}/login?redirectTo=results`; }; /** * Check if state is done updating from local storage * @returns {boolean|boolean} */ const checkStateUpdatedFromStorage = () => { return ( JSON.stringify(songs) === localStorage.getItem('songs') && JSON.stringify(playlist) === localStorage.getItem('playlist') && JSON.stringify(name) === localStorage.getItem('name') && JSON.stringify(count) === localStorage.getItem('count') && JSON.stringify(popularity) === localStorage.getItem('popularity') && JSON.stringify(danceability) === localStorage.getItem('danceability') && JSON.stringify(energy) === localStorage.getItem('energy') && JSON.stringify(acousticness) === localStorage.getItem('acousticness') && JSON.stringify(valence) === localStorage.getItem('valence') && JSON.stringify(tempo) === localStorage.getItem('tempo') && JSON.stringify(seeds) === localStorage.getItem('seeds') && JSON.stringify(seedColors) === localStorage.getItem('seedColors') ); }; /** * Restore results page state from localstorage */ const restoreState = () => { setLoading(true); initialFetchComplete.current = false; setSongs(JSON.parse(localStorage.getItem('songs'))); setPlaylist(JSON.parse(localStorage.getItem('playlist'))); setName(JSON.parse(localStorage.getItem('name'))); setCount(JSON.parse(localStorage.getItem('count'))); setPopularity(JSON.parse(localStorage.getItem('popularity'))); setDanceability(JSON.parse(localStorage.getItem('danceability'))); setEnergy(JSON.parse(localStorage.getItem('energy'))); setAcousticness(JSON.parse(localStorage.getItem('acousticness'))); setValence(JSON.parse(localStorage.getItem('valence'))); setTempo(JSON.parse(localStorage.getItem('tempo'))); setSeeds(JSON.parse(localStorage.getItem('seeds'))); setSeedColors(JSON.parse(localStorage.getItem('seedColors'))); setLoading(false); }; // Fetch initial songs and load useEffect(() => { ReactGA.pageview('/results'); ReactGA.set({ userId: Cookies.get('userID') }); if (checkStateStored()) { restoreState(); } else if (props?.location?.state?.seed) { initialFetchComplete.current = true; setSeeds(props.location.state.seed); setLoading(false); } else { // Immediately Invoked Function Expression (async () => { if (props?.location?.state?.playlist) { setPlaylist(props.location.state.playlist); } if (props?.location?.state?.playlist?.id || playlist?.id) { let id = playlist?.id || props?.location?.state?.playlist?.id; const url = process.env.REACT_APP_API_URL + '/results/' + id; try { let response = await transport.get(url); setSongs(response.data.songs); const parameters = response.data.parameters; setDanceability({ min: parameters.min_danceability, max: parameters.max_danceability, }); setAcousticness({ min: parameters.min_acousticness, max: parameters.max_acousticness, }); setPopularity({ min: parameters.min_popularity, max: parameters.max_popularity, }); setEnergy({ min: parameters.min_energy, max: parameters.max_energy, }); setValence({ min: parameters.min_valence, max: parameters.max_valence, }); setTempo({ min: parameters.min_tempo, max: parameters.max_tempo, }); let [artists, tracks] = await Promise.all([ getArtists(accessToken, parameters.seed_artists), getTracks(accessToken, parameters.seed_tracks), ]); setSeeds({ artists: artists, tracks: tracks, }); let playlistName = playlist?.name || props?.location?.state?.playlist?.name; setName(`remixr:${playlistName}`); initialFetchComplete.current = true; setLoading(false); } catch (e) { console.log(e); setError(true); } } })(); } }, []); // Update generated songs if parameters are changed useEffect(() => { if (!loading && initialFetchComplete.current) { // To prevent effect from refreshing songlist while restoring from localStorage console.log('Running seeds effect'); setLoading(true); setGeneratedPlaylistLink(null); let parameters = { popularity, danceability, energy, acousticness, valence, tempo, }; getRecommendations(accessToken, parameters, seeds, count) .then((songs) => { setSongs(songs); setLoading(false); }) .catch((error) => { console.log(error); setError(true); }); } else if (checkStateStored() && checkStateUpdatedFromStorage()) { // Runs once state is fully restored from local storage initialFetchComplete.current = true; localStorage.clear(); } }, [count, popularity, danceability, energy, tempo, acousticness, valence, seeds]); // Calculate colors for seeds useEffect(() => { // Using IIFE for async effect seeds && seeds.artists && seeds.tracks && (async () => { let items = [...seeds.artists, ...seeds.tracks]; let promiseArray = [...seeds.artists, ...seeds.tracks].map((item) => Vibrant.from(item.image) .getPalette() .then((palette) => palette.Vibrant._rgb.toString()) ); let colors = await Promise.all(promiseArray); let colorStyles = {}; for (let i = 0; i < items.length; i++) { colorStyles[items[i].id] = `rgba(${colors[i]},0.6)`; } setSeedColors(colorStyles); })(); }, [seeds]); // If invalid access if (!(props?.location?.state?.playlist || props?.location?.state?.seed || checkStateStored() || songs)) { return <Redirect to="/" />; } const savePlaylist = () => { ReactGA.event({ category: 'Save playlist', action: 'Click save button', }); const url = process.env.REACT_APP_API_URL + '/save'; transport .post(url, { name, tracks: songs.map((item) => item.uri), }) .then( (response) => { console.log('Saved playlist'); console.log(response); setGeneratedPlaylistLink(response.data.link); }, (error) => { console.log(error); } ); }; const removeSeed = (item, type) => { ReactGA.event({ category: 'Seeds', action: 'Remove seed', label: 'Results', }); if (seeds.artists.length + seeds.tracks.length <= 1) { message.error('Cannot remove all seeds'); ReactGA.event({ category: 'Seeds', action: 'Remove all seeds error', label: 'Results', }); } else { setSeeds({ artists: type === 'artist' ? seeds.artists.filter((artist) => artist.id !== item.id) : seeds.artists, tracks: type === 'track' ? seeds.tracks.filter((track) => track.id !== item.id) : seeds.tracks, }); } }; const addSeed = (item, type) => { if (seeds.artists.length + seeds.tracks.length >= 5) { message.error('Cannot add more than five seeds'); ReactGA.event({ category: 'Seeds', action: 'Add extra seeds error', label: 'Results', }); } else { setSeeds({ artists: type === 'artist' ? [...seeds.artists, item] : seeds.artists, tracks: type === 'track' ? [...seeds.tracks, item] : seeds.tracks, }); } }; const seedTags = ( <Space className="tagsList" size={1}> {seeds && seeds.artists && seeds.artists.map((artist) => ( <Tag style={ seedColors && seedColors[artist.id] && { backgroundColor: seedColors[artist.id], } } className="seedTag" key={artist.id} closable onClose={(e) => { e.preventDefault(); removeSeed(artist, 'artist'); }} > <img src={artist.image} width={60} height={60} alt="" /> <div className="tagName"> <span>{artist.name}</span> </div> </Tag> ))} {seeds && seeds.tracks && seeds.tracks.map((track) => ( <Tag className="seedTag" style={seedColors && seedColors[track.id] && { backgroundColor: seedColors[track.id] }} key={track.id} closable onClose={(e) => { e.preventDefault(); removeSeed(track, 'track'); }} > <img src={track.image} width={60} height={60} alt="" /> <div className="tagName"> <span>{track.name}</span> </div> </Tag> ))} </Space> ); const parametersMenu = ( <ParametersMenu values={{ count, energy, popularity, danceability, tempo, acousticness, valence, }} handlers={{ setCount, setEnergy, setPopularity, setDanceability, setTempo, setAcousticness, setValence, }} /> ); const access_token = Cookies.get('access_token'); const isLoggedIn = access_token !== undefined && access_token !== null && access_token !== ''; const savePlaylistMenu = ( <SavePlaylist name={name} setName={setName} saveHandler={savePlaylist} isLoggedIn={isLoggedIn} saveStateAndLogin={saveStateAndLogin} /> ); const playlistSuccessPage = <PlaylistSuccessPage link={generatedPlaylistLink} />; if (error) { return <ErrorScreen />; } return ( <div> <Navbar /> {playlist ? ( <Title style={{ textAlign: 'center' }} level={2}> Generated from: {playlist.name} </Title> ) : null} <SearchSeeds addSeed={addSeed} /> {seedTags} <Row> {/* Mobile settings drawer */} <Col xs={24} sm={24} md={24} lg={0} xl={0}> {!loading && generatedPlaylistLink ? playlistSuccessPage : savePlaylistMenu} <Collapse bordered={false} className="collapse-parameters rounded-component" onClick={() => { ReactGA.event({ category: 'Parameters', action: 'Opened parameters on Mobile', }); }} > <Panel header="Tune Playlist Settings" key="1"> {!loading && parametersMenu} </Panel> </Collapse> </Col> {/* Songs */} <Col xs={24} sm={24} md={24} lg={16} xl={16}> <SongList loading={loading} songs={songs} /> </Col> {/* Web settings drawer */} <Col xs={0} sm={0} md={0} lg={8} xl={8}> <Affix offsetTop={70}> {generatedPlaylistLink ? playlistSuccessPage : savePlaylistMenu} {!loading && ( <div className="parameters rounded-component"> <Title style={{ textAlign: 'center' }} level={3}> Tune playlist settings </Title> {parametersMenu} </div> )} </Affix> </Col> </Row> </div> ); }