Discovery engine prototype

This commit is contained in:
James Barnsley
2017-05-12 05:22:57 +12:00
parent 2b66b2a164
commit f6369ee895
13 changed files with 590 additions and 60 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

3
src/js/bootstrap.js vendored
View File

@ -49,7 +49,8 @@ var initialState = {
},
spotify: {
connected: false,
me: false
me: false,
autocomplete_results: {}
},
ui: {
current_tracklist: [],

View File

@ -25,10 +25,10 @@ export default class ArtistSentence extends React.Component{
}
if (!artist.name){
var content = <span>-</span>
} else if (artist.uri){
var content = <Link className="artist" to={global.baseURL+'artist/'+artist.uri}>{ artist.name }</Link>
}else{
} else if (!artist.uri || this.props.nolinks){
var content = <span>{ artist.name }</span>
}else{
var content = <Link className="artist" to={global.baseURL+'artist/'+artist.uri}>{ artist.name }</Link>
}
return (
<span key={'index_'+artist.uri}>

View File

@ -0,0 +1,136 @@
import React, { PropTypes } from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import ArtistSentence from './ArtistSentence'
import * as helpers from '../helpers'
import * as spotifyActions from '../services/spotify/actions'
class AutocompleteField extends React.Component{
constructor(props) {
super(props)
this.state = {
value: ''
}
this.id = helpers.generateGuid()
this.timer = null
this.handleClick = this.handleClick.bind(this)
}
componentDidMount(){
window.addEventListener("click", this.handleClick, false)
}
componentWillUnmount(){
window.removeEventListener("click", this.handleClick, false)
}
handleClick(e){
if ($(e.target).closest('.autocomplete-field').length <= 0){
this.props.spotifyActions.clearAutocompleteResults()
}
}
handleChange(value){
var self = this
clearTimeout(this.timer)
this.setState({value: value})
this.timer = setTimeout(
function(){
self.setState({searching: true})
self.props.spotifyActions.getAutocompleteResults(self.id,value,self.props.types)
},
500
)
}
handleSelect(item){
if (this.props.clearOnSelect){
this.setState({value: ''})
} else {
this.setState({value: item.name})
}
this.props.handleSelect(item)
this.props.spotifyActions.clearAutocompleteResults(this.id)
}
results(){
if (typeof(this.props.results) === 'undefined'){
return null
} else if (typeof(this.props.results[this.id]) === 'undefined'){
return null
} else {
return this.props.results[this.id]
}
}
renderResults(type){
var results = this.results()
if (!results || typeof(results[type]) === 'undefined' || results[type].length <= 0) return null
// only show the first 3
var items = results[type].slice(0,3)
return (
<div className="type">
<h4 className="grey-text">{type}</h4>
{
items.map(item => {
return (
<div className="result" key={item.uri} onClick={() => this.handleSelect(item)}>
{item.name}
{type == 'tracks' ? <span className="grey-text"> <ArtistSentence artists={item.artists} nolinks /></span> : null}
</div>
)
})
}
</div>
)
}
render(){
var className = "field autocomplete-field"
if (this.results() && this.results().loading){
className += " loading"
}
return (
<div className={className}>
<div className="input">
<input
type="text"
value={this.state.value}
onChange={e => this.handleChange(e.target.value)}
placeholder={this.props.placeholder ? this.props.placeholder : "Start typing..."} />
</div>
<div className="results">
{
this.props.types.map(type => {
return (
<div key={type}>
{this.renderResults(type+'s')}
</div>
)
})
}
</div>
</div>
);
}
}
const mapStateToProps = (state, ownProps) => {
return {
results: (state.spotify.autocomplete_results ? state.spotify.autocomplete_results : {})
}
}
const mapDispatchToProps = (dispatch) => {
return {
spotifyActions: bindActionCreators(spotifyActions, dispatch)
}
}
export default connect(mapStateToProps, mapDispatchToProps)(AutocompleteField)

View File

@ -39,7 +39,7 @@ class Sidebar extends React.Component{
<section>
<title>Discover</title>
<Link activeClassName="active" disabled={!this.props.spotify_authorized} to={this.props.spotify_authorized ? global.baseURL+"discover" : null}>
<Link activeClassName="active" to={global.baseURL+"discover"}>
<Icon name="compass" className="white" />
Discover
</Link>

View File

@ -203,6 +203,12 @@ export let getFromUri = function(element,uri){
return exploded[2]
}
break
case 'genreid':
if (exploded[1] == 'genre'){
return exploded[2]
}
break
}
return null
}
@ -225,6 +231,7 @@ export let uriType = function( uri ){
case 'album':
case 'playlist':
case 'search':
case 'genre':
return exploded[1]
break

View File

@ -406,8 +406,8 @@ export function getURL( url, action_name, key = false ){
}
}
export function getSearchResults( query, type = 'album,artist,playlist,track', limit = 50, offset = 0 ){
return (dispatch, getState) => {
export function getSearchResults(query, type = 'album,artist,playlist,track', limit = 50, offset = 0){
return (dispatch, getState) => {
var url = 'search?q='+query
url += '&type='+type
@ -456,7 +456,62 @@ export function getSearchResults( query, type = 'album,artist,playlist,track', l
tracks_more: response.tracks.next
});
});
}
}
}
export function getAutocompleteResults(field_id, query, types = ['album','artist','playlist','track']){
return (dispatch, getState) => {
console.log(types)
dispatch({type: 'SPOTIFY_AUTOCOMPLETE_LOADING', field_id: field_id})
var genre_included = types.includes('genre')
if (genre_included){
var index = types.indexOf('genre')
types.splice(index,1)
}
var endpoint = 'search?q='+query
endpoint += '&type='+types.join(',')
endpoint += '&country='+getState().spotify.country
$.when(
sendRequest(dispatch, getState, endpoint),
sendRequest(dispatch, getState, 'recommendations/available-genre-seeds')
).then((search_response, genres_response) => {
var genres = []
if (genre_included){
for (var i = 0; i < genres_response.genres.length; i++){
if (genres_response.genres[i].includes(query)){
var genre = genres_response.genres[i]
genres.push({
name: (genre.charAt(0).toUpperCase()+genre.slice(1)).replace('-',' '),
uri: 'spotify:genre:'+genre
})
}
}
}
dispatch({
type: 'SPOTIFY_AUTOCOMPLETE_LOADED',
field_id: field_id,
results: {
artists: (search_response.artists ? search_response.artists.items : []),
albums: (search_response.albums ? search_response.albums.items : []),
playlists: (search_response.playlists ? search_response.playlists.items : []),
tracks: (search_response.tracks ? search_response.tracks.items : []),
genres: genres
}
});
})
}
}
export function clearAutocompleteResults(field_id = null){
return {
type: 'SPOTIFY_AUTOCOMPLETE_CLEAR',
field_id: field_id
}
}
export function following(uri, method = 'GET'){
@ -568,43 +623,99 @@ export function resolveRadioSeeds( radio ){
/**
* Get our recommendations
* This is based off our 'favorites' and then we use those as seeds
* Get my favorites
*
* @param uri string
**/
export function getDiscover(){
export function getFavorites(limit = 50, term = 'long_term'){
return (dispatch, getState) => {
dispatch({ type: 'SPOTIFY_DISCOVER_LOADED', data: false })
dispatch({type: 'SPOTIFY_FAVORITES_LOADED', artists: [], tracks: []})
// get favorite tracks
sendRequest( dispatch, getState, 'me/top/tracks?limit=50&time_range=long_term' )
$.when(
sendRequest(dispatch, getState, 'me/top/artists?limit='+limit+'&time_range='+term),
sendRequest(dispatch, getState, 'me/top/tracks?limit='+limit+'&time_range='+term)
).then((artists_response, tracks_response) => {
dispatch({
type: 'SPOTIFY_FAVORITES_LOADED',
artists: artists_response.items,
tracks: tracks_response.items
});
})
}
}
/**
* Get our recommendations
* This is based off our 'favorites' and then we use those as seeds
*
* @param uris = array of artist or track URIs or a genre string
**/
export function getRecommendations(uris = []){
return (dispatch, getState) => {
dispatch({ type: 'SPOTIFY_RECOMMENDATIONS_LOADED', data: false })
// build our starting point
var artists_ids = []
var tracks_ids = []
var genres = []
for (var i = 0; i < uris.length; i++){
var uri = uris[i]
switch (helpers.uriType(uri)){
case 'artist':
artists_ids.push(helpers.getFromUri('artistid',uri))
break
case 'track':
tracks_ids.push(helpers.getFromUri('trackid',uri))
break
case 'genre':
genres.push(helpers.getFromUri('genreid',uri))
break
case 'default':
genres.push(uri)
break
}
}
// construct our endpoint URL with all the appropriate arguments
var endpoint = 'recommendations'
endpoint += '?seed_artists='+artists_ids.join(',')
endpoint += '&seed_tracks='+tracks_ids.join(',')
endpoint += '&seed_genres='+genres.join(',')
sendRequest(dispatch, getState, endpoint)
.then( response => {
dispatch({
type: 'SPOTIFY_RECOMMENDATIONS_LOADED',
tracks: response.tracks
});
})
}
}
var favorite_tracks_uris = helpers.asURIs(response.items)
favorite_tracks_uris = favorite_tracks_uris.sort(() => .5 - Math.random())
favorite_tracks_uris = favorite_tracks_uris.slice(0,10)
for (var i = 0; i < favorite_tracks_uris.length; i++){
var seed_id = helpers.getFromUri('trackid',favorite_tracks_uris[i] )
$.when(
sendRequest( dispatch, getState, 'recommendations?seed_tracks='+seed_id ),
sendRequest( dispatch, getState, 'tracks/'+seed_id )
).then( ( tracks_response, seed_response ) => {
dispatch({
type: 'SPOTIFY_DISCOVER_LOADED',
data: {
tracks: tracks_response.tracks,
seed: seed_response
}
});
});
}
/**
* Get all the available genres
*
* @param uri string
**/
export function getGenres(){
return (dispatch, getState) => {
sendRequest(dispatch, getState, 'recommendations/available-genre-seeds')
.then( response => {
dispatch({
type: 'SPOTIFY_GENRES_LOADED',
genres: response
});
})
}
}

View File

@ -179,6 +179,59 @@ export default function reducer(spotify = {}, action){
if( !action.data ) return Object.assign({}, spotify, { discover: [] })
return Object.assign({}, spotify, { discover: [...spotify.discover, ...[action.data]] })
case 'SPOTIFY_GENRES_LOADED':
return Object.assign({}, spotify, {genres: action.genres})
case 'SPOTIFY_RECOMMENDATIONS_LOADED':
return Object.assign(
{},
spotify,
{
recommendations: action.tracks
})
case 'SPOTIFY_FAVORITES_LOADED':
return Object.assign(
{},
spotify,
{
favorite_artists: action.artists,
favorite_tracks: action.tracks
})
case 'SPOTIFY_AUTOCOMPLETE_LOADING':
var autocomplete_results = spotify.autocomplete_results
autocomplete_results[action.field_id] = {loading: true}
return Object.assign(
{},
spotify,
{
autocomplete_results: autocomplete_results
})
case 'SPOTIFY_AUTOCOMPLETE_LOADED':
var autocomplete_results = spotify.autocomplete_results
autocomplete_results[action.field_id] = action.results
autocomplete_results[action.field_id].loading = false
return Object.assign(
{},
spotify,
{
autocomplete_results: autocomplete_results
})
case 'SPOTIFY_AUTOCOMPLETE_CLEAR':
var autocomplete_results = spotify.autocomplete_results
if (typeof(autocomplete_results[action.field_id]) !== 'undefined'){
delete autocomplete_results[action.field_id]
}
return Object.assign(
{},
spotify,
{
autocomplete_results: autocomplete_results
})
default:
return spotify
}

View File

@ -2,10 +2,14 @@
import React, { PropTypes } from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import FontAwesome from 'react-fontawesome'
import Header from '../../components/Header'
import SidebarToggleButton from '../../components/SidebarToggleButton'
import GridSlider from '../../components/GridSlider'
import ArtistSentence from '../../components/ArtistSentence'
import TrackList from '../../components/TrackList'
import Parallax from '../../components/Parallax'
import AutocompleteField from '../../components/AutocompleteField'
import * as helpers from '../../helpers'
import * as spotifyActions from '../../services/spotify/actions'
@ -13,35 +17,88 @@ import * as spotifyActions from '../../services/spotify/actions'
class Discover extends React.Component{
constructor(props) {
super(props);
super(props)
this._autocomplete_timer = false
this.state = {
seeds: [],
adding_seed: false
}
}
componentDidMount(){
this.props.spotifyActions.getDiscover()
this.props.spotifyActions.getGenres()
if (this.props.authorized){
this.props.spotifyActions.getFavorites()
}
}
renderRecommendations(){
if (!this.props.discover || this.props.discover.length <= 0) return null
componentWillReceiveProps(newProps, newState){
if (this.props.favorite_artists.length <= 0 && newProps.favorite_artists.length > 0 && this.state.seeds.length <= 0){
var initial_seeds = newProps.favorite_artists.sort(() => .5 - Math.random())
initial_seeds = initial_seeds.slice(0,2)
this.setState({seeds: initial_seeds})
this.getRecommendations(initial_seeds)
}
}
getRecommendations(seeds = this.state.seeds){
var uris = helpers.asURIs(seeds)
this.props.spotifyActions.getRecommendations(uris)
}
removeSeed(index){
var seeds = this.state.seeds
seeds.splice(index,1)
this.setState({seeds: seeds})
this.getRecommendations(seeds)
}
handleSelect(item){
var seeds = this.state.seeds
seeds.push(item)
this.setState({seeds: seeds})
this.getRecommendations(seeds)
}
renderSeeds(){
return (
<section className="recommendations">
<div className="seeds">
{
this.props.discover.map(
(discover, index) => {
var title = <h4>Because you listened to <ArtistSentence artists={discover.seed.artists} /></h4>
return <GridSlider title={title} tracks={discover.tracks} key={index} />
this.state.seeds.map((seed,index) => {
var type = helpers.uriType(seed.uri)
if (!type){
type = 'genre'
}
)
return (
<span className="seed" key={seed.uri}>
{seed.name}
<span className="type">({type})</span>
<FontAwesome name="close" className="remove" onClick={() => this.removeSeed(index)} />
</span>
)
})
}
</section>
<AutocompleteField types={['artist','track','genre']} placeholder="Add seed" handleSelect={item => this.handleSelect(item)} clearOnSelect />
</div>
)
}
render(){
return (
<div className="view discover-view">
<Header icon="compass" title="Discover" />
{ this.renderRecommendations() }
<SidebarToggleButton />
<div className="intro">
<Parallax image="/iris/assets/backgrounds/discover.jpg" />
<div className="liner">
<h1>Discover new music</h1>
<h3>Add seeds below to build your sound</h3>
{this.renderSeeds()}
</div>
</div>
{this.props.recommendations ? <section className="list-wrapper"><TrackList className="discover-track-list" uri="iris:discover" tracks={this.props.recommendations} /></section> : null}
</div>
)
}
@ -57,7 +114,10 @@ class Discover extends React.Component{
const mapStateToProps = (state, ownProps) => {
return {
authorized: state.spotify.authorized,
discover: state.spotify.discover
quick_search_results: (state.spotify.quick_search_results ? state.spotify.quick_search_results : {artists: [], tracks: []}),
recommendations: (state.spotify.recommendations ? state.spotify.recommendations : []),
favorite_artists: (state.spotify.favorite_artists ? state.spotify.favorite_artists : []),
favorite_tracks: (state.spotify.favorite_tracks ? state.spotify.favorite_tracks : [])
}
}

View File

@ -22,6 +22,7 @@
@import 'components/header';
@import 'components/notifications';
@import 'components/dropdown-field';
@import 'components/autocomplete-field';
@import 'components/debug';
@import 'views/artist';

View File

@ -0,0 +1,62 @@
.autocomplete-field {
position: relative;
.input {
width: 100%;
}
&.loading {
.input {
position: relative;
&:after {
position: absolute;
display: block;
content: '';
background: rgba(255,255,255,0.2);
opacity: 1;
animation: slideloader 1s infinite;
bottom: 0;
left: 0;
right: 0;
height: 4px;
z-index: 1;
}
}
}
.results {
position: absolute;
top: 37px;
left: 0;
right: 0;
z-index: 97;
background: $dark_grey;
color: $white;
.type {
&:not(:first-child){
padding-top: 5px;
}
h4 {
padding: 10px 14px 4px;
margin: 0;
font-size: 12px;
}
.result {
display: block;
padding: 10px 14px;
border-top: 1px solid lighten($dark_grey, 10%);
cursor: pointer;
&:hover {
background: lighten($dark_grey, 10%);
}
}
}
}
}

View File

@ -80,16 +80,30 @@ input[type="submit"] {
}
&.secondary {
color: #000000;
border-color: #000000;
color: $black;
border-color: $black;
&:hover {
color: lighten( #000000, 40% );
border-color: lighten( #000000, 40% );
color: lighten( $black, 40% );
border-color: lighten( $black, 40% );
}
&:active {
color: #FFFFFF;
background: #000000;
border-color: #000000;
color: $white;
background: $black;
border-color: $black;
}
}
&.alternative {
color: $blue;
border-color: $blue;
&:hover {
color: darken($blue, 10%);
border-color: darken($blue, 10%);
}
&:active {
color: $white;
background: $blue;
border-color: $blue;
}
}

View File

@ -1,7 +1,92 @@
.discover-view {
section {
padding: 40px;
.intro {
position: relative;
padding: 60px 40px;
.parallax {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.liner {
z-index: 2;
position: relative;
color: $white;
h1 {
padding-bottom: 8px;
}
h3 {
padding-bottom: 40px;
opacity: 0.5;
}
}
}
.seeds {
.seed {
display: inline-block;
background: $blue;
color: $white;
padding: 8px 26px 8px 14px;
font-size: 16px;
position: relative;
vertical-align: top;
margin: 0 10px 10px 0;
.type {
display: inline-block;
font-size: 12px;
opacity: 0.5;
padding: 0 10px 0 5px;
}
.remove {
font-size: 10px;
padding: 12px;
position: absolute;
top: 0;
right: 0;
cursor: pointer;
}
}
}
.autocomplete-field {
width: 250px;
display: inline-block;
vertical-align: top;
padding: 0;
.input {
float: none;
margin: 0 10px 10px 0;
input {
background: rgba(100,100,100,0.5);
color: $white;
padding: 7px 13px;
font-size: 16px;
border: 1px solid transparent;
&:focus,
&:active {
border-color: $blue;
}
}
&:after {
background: $blue;
}
}
}
@include responsive( $bp_medium ){