Reorganising component folder structure

This commit is contained in:
James Barnsley
2018-04-19 13:34:16 +12:00
parent 71071da0c7
commit cd52734bcc
38 changed files with 2453 additions and 2723 deletions

View File

@ -0,0 +1,153 @@
import React, { PropTypes } from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import { Link, hashHistory } from 'react-router'
import ArtistSentence from './../ArtistSentence'
import * as helpers from '../../helpers'
import * as coreActions from '../../services/core/actions'
import * as uiActions from '../../services/ui/actions'
import * as spotifyActions from '../../services/spotify/actions'
class AddSeedField 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)
if (!this.props.genres){
this.props.spotifyActions.getGenres()
}
}
componentWillUnmount(){
window.removeEventListener("click", this.handleClick, false)
}
handleClick(e){
if ($(e.target).closest('.add-seed-field').length <= 0){
this.props.spotifyActions.clearAutocompleteResults(this.id)
}
}
handleChange(e,value){
var self = this
// update our local state
this.setState({value: value})
// start a timer to perform the actual search
// this provides a wee delay between key presses to avoid request spamming
clearTimeout(this.timer)
this.timer = setTimeout(
function(){
self.setState({searching: true})
self.props.spotifyActions.getAutocompleteResults(self.id,value,['artist','track','genre'])
},
500
)
}
handleSelect(e,item){
this.setState({value: ''})
this.props.onSelect(e,item.uri)
this.props.spotifyActions.clearAutocompleteResults(this.id)
// Add our selected item to our global index
switch (helpers.uriType(item.uri)){
case 'artist':
this.props.coreActions.artistsLoaded(item);
break;
case 'track':
this.props.coreActions.tracksLoaded(item);
break;
}
}
results(){
if (this.props.results === undefined){
return null;
} else if (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={e => this.handleSelect(e,item)}>
{item.name}
{type == 'tracks' ? <span className="grey-text"> <ArtistSentence artists={item.artists} nolinks /></span> : null}
</div>
)
})
}
</div>
)
}
render(){
var className = "field autocomplete-field add-seed-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,e.target.value)}
placeholder={this.props.placeholder ? this.props.placeholder : "Start typing..."} />
</div>
<div className="results">
{this.renderResults('artists')}
{this.renderResults('tracks')}
{this.renderResults('genres')}
</div>
</div>
);
}
}
const mapStateToProps = (state, ownProps) => {
return {
genres: (state.spotify.genres ? state.spotify.genres : null),
results: (state.spotify.autocomplete_results ? state.spotify.autocomplete_results : {})
}
}
const mapDispatchToProps = (dispatch) => {
return {
coreActions: bindActionCreators(coreActions, dispatch),
uiActions: bindActionCreators(uiActions, dispatch),
spotifyActions: bindActionCreators(spotifyActions, dispatch)
}
}
export default connect(mapStateToProps, mapDispatchToProps)(AddSeedField)

View File

@ -0,0 +1,153 @@
import React, { PropTypes } from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import { Link, hashHistory } from 'react-router'
import ArtistSentence from './../ArtistSentence'
import * as helpers from '../../helpers'
import * as coreActions from '../../services/core/actions'
import * as uiActions from '../../services/ui/actions'
import * as spotifyActions from '../../services/spotify/actions'
class AddSeedField 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)
if (!this.props.genres){
this.props.spotifyActions.getGenres()
}
}
componentWillUnmount(){
window.removeEventListener("click", this.handleClick, false)
}
handleClick(e){
if ($(e.target).closest('.add-seed-field').length <= 0){
this.props.spotifyActions.clearAutocompleteResults(this.id)
}
}
handleChange(e,value){
var self = this
// update our local state
this.setState({value: value})
// start a timer to perform the actual search
// this provides a wee delay between key presses to avoid request spamming
clearTimeout(this.timer)
this.timer = setTimeout(
function(){
self.setState({searching: true})
self.props.spotifyActions.getAutocompleteResults(self.id,value,['artist','track','genre'])
},
500
)
}
handleSelect(e,item){
this.setState({value: ''})
this.props.onSelect(e,item.uri)
this.props.spotifyActions.clearAutocompleteResults(this.id)
// Add our selected item to our global index
switch (helpers.uriType(item.uri)){
case 'artist':
this.props.coreActions.artistsLoaded(item);
break;
case 'track':
this.props.coreActions.tracksLoaded(item);
break;
}
}
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={e => this.handleSelect(e,item)}>
{item.name}
{type == 'tracks' ? <span className="grey-text"> <ArtistSentence artists={item.artists} nolinks /></span> : null}
</div>
)
})
}
</div>
)
}
render(){
var className = "field autocomplete-field add-seed-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,e.target.value)}
placeholder={this.props.placeholder ? this.props.placeholder : "Start typing..."} />
</div>
<div className="results">
{this.renderResults('artists')}
{this.renderResults('tracks')}
{this.renderResults('genres')}
</div>
</div>
);
}
}
const mapStateToProps = (state, ownProps) => {
return {
genres: (state.ui.genres ? state.ui.genres : null),
results: (state.spotify.autocomplete_results ? state.spotify.autocomplete_results : {})
}
}
const mapDispatchToProps = (dispatch) => {
return {
coreActions: bindActionCreators(coreActions, dispatch),
uiActions: bindActionCreators(uiActions, dispatch),
spotifyActions: bindActionCreators(spotifyActions, dispatch)
}
}
export default connect(mapStateToProps, mapDispatchToProps)(AddSeedField)

View File

@ -0,0 +1,82 @@
import React, { PropTypes } from 'react'
import FontAwesome from 'react-fontawesome'
export default class ConfirmationButton extends React.Component{
constructor(props){
super(props);
this.state = {
timing_out: false,
confirming: false
}
this.confirming = false;
this.unconfirmTimer = false;
}
componentWillUnmount(){
clearTimeout(this.unconfirmTimer );
}
handleClick(e){
if (this.state.confirming){
this.setState({ confirming: false });
this.props.onConfirm();
} else {
this.setState({ confirming: true });
}
}
handleMouseEnter(e){
this.setState({ timing_out: false });
clearTimeout(this.unconfirmTimer );
}
handleMouseLeave(e){
if (this.state.confirming){
this.setState({ timing_out: true });
this.unconfirmTimer = setTimeout(
function(){
this.setState({ confirming: false });
}.bind(this),
2000
);
}
}
render(){
var className = 'button';
var content = this.props.content;
if (this.state.confirming){
className += ' confirming';
content = this.props.confirmingContent;
if (this.state.timing_out){
className += ' timing-out';
}
}
if (this.props.working){
className += ' working';
if (this.props.workingContent){
content = this.props.workingContent;
}
}
if (this.props.className){
className += ' '+this.props.className;
}
return (
<button
className={className}
onClick={ (e) => this.handleClick(e) }
onMouseLeave={ (e) => this.handleMouseLeave(e) }
onMouseEnter={ (e) => this.handleMouseEnter(e) }>
{ content }
</button>
);
}
}

View File

@ -0,0 +1,92 @@
import React, { PropTypes } from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import FontAwesome from 'react-fontawesome'
export default class DropdownField extends React.Component{
constructor(props){
super(props);
this.state = {
expanded: false
}
this.handleClick = this.handleClick.bind(this)
}
componentDidMount(){
window.addEventListener("click", this.handleClick, false)
}
componentWillUnmount(){
window.removeEventListener("click", this.handleClick, false)
}
handleClick(e){
// TODO: remove dependency on jQuery and explore the performance of this functionality
if ($(e.target).closest('.dropdown-field').data('key') != this.props.name.replace(' ','_').toLowerCase() && this.state.expanded){
this.setState({ expanded: false })
}
}
handleChange(value){
this.setState({ expanded: !this.state.expanded })
return this.props.handleChange(value )
}
handleToggle(){
this.setState({ expanded: !this.state.expanded })
}
render(){
if (!this.props.options){
return null;
}
var className = 'dropdown-field';
if (this.state.expanded){
className += ' expanded';
}
if (this.props.no_status_icon){
className += ' no-status-icon';
}
if (this.props.button){
className += ' buttonify';
}
var current_value = this.props.options[0].value
if (this.props.value){
current_value = this.props.value;
}
var icon = <FontAwesome name="check" />
if (this.props.reverse !== undefined){
if (this.props.reverse){
icon = <FontAwesome className="reverse" name="caret-down" />
} else {
icon = <FontAwesome className="reverse" name="caret-up" />
}
}
return (
<div className={className} data-key={this.props.name.replace(' ','_').toLowerCase()}>
<div className={"label"+(this.props.button ? " button "+this.props.button : "")} onClick={ () => this.handleToggle() }>
{this.props.icon ? <span><FontAwesome name={this.props.icon} />&nbsp; </span> : null}
<span className="text">{ this.props.name }</span>
</div>
<div className="options">
{
this.props.options.map(option => {
return (
<div className="option" key={ option.value } onClick={ e => this.handleChange(option.value) }>
{!this.props.no_status_icon && (option.value == current_value) ? icon : null}
{option.label}
</div>
)
})
}
</div>
</div>
)
}
}

View File

@ -0,0 +1,49 @@
import React, { PropTypes } from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import Icon from './../Icon'
export default class Dropzone extends React.Component{
constructor(props){
super(props)
this.state = {
hover: false
}
this.handleMouseOver = this.handleMouseOver.bind(this)
this.handleMouseOut = this.handleMouseOut.bind(this)
}
componentWillMount(){
window.addEventListener("mouseover", this.handleMouseOver, false);
window.addEventListener("mouseout", this.handleMouseOut, false);
}
componentWillUnmount(){
window.removeEventListener("mouseover", this.handleMouseOver, false);
window.removeEventListener("mouseout", this.handleMouseOut, false);
}
handleMouseOver(e){
this.setState({hover: true})
}
handleMouseOut(e){
this.setState({hover: false})
}
render(){
if (!this.props.data ) return null
return (
<div className={this.state.hover?"dropzone hover":"dropzone"} onMouseUp={ e => this.props.handleMouseUp(e) }>
<Icon className="white" name={ this.props.data.icon } />
<span className="title">{ this.props.data.title }</span>
</div>
);
}
}

View File

@ -0,0 +1,94 @@
import React, { PropTypes } from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import Dropzone from './Dropzone'
import * as uiActions from '../../services/ui/actions'
import * as mopidyActions from '../../services/mopidy/actions'
import * as spotifyActions from '../../services/spotify/actions'
class Dropzones extends React.Component{
constructor(props){
super(props);
this._zones = [
{
title: 'Add to queue',
icon: 'play',
action: 'enqueue'
},
{
title: 'Play next',
icon: 'play',
action: 'enqueue_next'
},
{
title: 'Add to playlist',
icon: 'playlist',
action: 'add_to_playlist',
accepts: ['tltrack','track','album','playlist','artist']
}
]
}
handleMouseMove(e){
if (!this.props.dragger || !this.props.dragger.active ) return null;
this.props.uiActions.dragMove(e )
}
handleMouseUp(e, index){
var target = this._zones[index]
var victims = this.props.dragger.victims
var uris = []
for(var i = 0; i < victims.length; i++){
uris.push(victims[i].uri )
}
switch(target.action){
case 'enqueue':
this.props.mopidyActions.enqueueURIs(uris, this.props.dragger.from_uri)
break
case 'enqueue_next':
this.props.mopidyActions.enqueueURIs(uris, this.props.dragger.from_uri, true)
break
case 'add_to_playlist':
this.props.uiActions.openModal('add_to_playlist',{tracks_uris: uris})
break
}
}
render(){
if (!this.props.dragger || !this.props.dragger.active ) return null
return (
<div className="dropzones">
{
this._zones.map((zone, index) => {
return <Dropzone key={index} data={zone} handleMouseUp={ e => this.handleMouseUp(e, index) }/>
})
}
</div>
);
}
}
const mapStateToProps = (state, ownProps) => {
return {
dragger: state.ui.dragger
}
}
const mapDispatchToProps = (dispatch) => {
return {
uiActions: bindActionCreators(uiActions, dispatch),
spotifyActions: bindActionCreators(spotifyActions, dispatch),
mopidyActions: bindActionCreators(mopidyActions, dispatch)
}
}
export default connect(mapStateToProps, mapDispatchToProps)(Dropzones)

View File

@ -0,0 +1,74 @@
import React, { PropTypes } from 'react'
import { Link } from 'react-router'
import FontAwesome from 'react-fontawesome'
export default class FilterField extends React.Component{
constructor(props){
super(props)
this.state = {
value: '',
active: (this.props.slim_mode ? true : false)
}
this.handleKeyUp = this.handleKeyUp.bind(this)
}
componentWillMount(){
window.addEventListener("keyup", this.handleKeyUp, false)
}
componentWillUnmount(){
window.removeEventListener("keyup", this.handleKeyUp, false)
}
handleKeyUp(e){
if (e.keyCode == 27 && !this.props.slim_mode){
e.preventDefault()
this.setState({
value: '',
active: false
})
this.handleChange('')
}
}
activate(){
this.setState({active: true})
}
handleChange(value){
this.setState({
value: value,
active: (this.props.slim_mode ? true : (value != ''))
})
this.props.handleChange(value)
}
handleBlur(){
if (this.state.value == '' && !this.props.slim_mode){
this.setState({active: false})
}
}
render(){
return (
<span className={"filter-field "+(this.state.active ? 'active' : '')} onClick={e => this.activate()}>
<form>
<FontAwesome name="search" />
<input
type="text"
placeholder="Filter"
value={this.state.value}
onFocus={e => this.activate()}
onBlur={e => this.handleBlur()}
onChange={e => this.handleChange(e.target.value)}
/>
</form>
</span>
)
}
}

View File

@ -0,0 +1,67 @@
import React, { PropTypes } from 'react'
import { connect } from 'react-redux'
import { Link } from 'react-router'
import { createStore, bindActionCreators } from 'redux'
import FontAwesome from 'react-fontawesome'
import * as helpers from '../../helpers'
import * as uiActions from '../../services/ui/actions'
import * as spotifyActions from '../../services/spotify/actions'
class FollowButton extends React.Component{
constructor(props){
super(props);
}
remove(){
this.props.spotifyActions.following(this.props.uri, 'DELETE');
}
add(){
this.props.spotifyActions.following(this.props.uri, 'PUT');
}
render(){
if (!this.props.uri){
return false;
}
var className = '';
// Inherit passed-down classes
if (this.props.className){
className += ' '+this.props.className;
}
// Loader
if (helpers.isLoading(this.props.load_queue,['/following','/followers','me/albums/contains/?ids=','me/albums/?ids='])){
className += ' working';
}
if (!this.props.spotify_authorized){
return <button className={className+' disabled'} onClick={e => this.props.uiActions.createNotification({content: 'You must authorize Spotify first', type: 'warning'})}>{this.props.addText}</button>
} else if (this.props.is_following === true){
return <button className={className+' destructive'} onClick={e => this.remove()}>{this.props.removeText}</button>
} else {
return <button className={className} onClick={e => this.add()}>{this.props.addText}</button>
}
}
}
const mapStateToProps = (state, ownProps) => {
return {
load_queue: state.ui.load_queue,
spotify_authorized: state.spotify.authorization
}
}
const mapDispatchToProps = (dispatch) => {
return {
uiActions: bindActionCreators(uiActions, dispatch),
spotifyActions: bindActionCreators(spotifyActions, dispatch)
}
}
export default connect(mapStateToProps, mapDispatchToProps)(FollowButton)

View File

@ -0,0 +1,124 @@
import React, { PropTypes } from 'react'
import { connect } from 'react-redux'
import { createStore, bindActionCreators } from 'redux'
import ReactGA from 'react-ga'
import FontAwesome from 'react-fontawesome'
import Thumbnail from '../Thumbnail'
import * as uiActions from '../../services/ui/actions'
import * as lastfmActions from '../../services/lastfm/actions'
class LastfmAuthenticationFrame extends React.Component{
constructor(props){
super(props);
this.state = {
authorizing: false
}
}
componentDidMount(){
let self = this;
// Listen for incoming messages from the authorization popup
window.addEventListener('message', function(event){
var data = JSON.parse(event.data);
// Only digest messages relevant to us
if (data.origin == 'auth_lastfm'){
self.handleMessage(event, data);
}
}, false);
}
handleMessage(event, data){
// Only allow incoming data from our authorized authenticator proxy
var authorization_domain = this.props.authorization_url.substring(0,this.props.authorization_url.indexOf('/',8))
if (event.origin != authorization_domain){
this.props.uiActions.createNotification({content: 'Authorization failed. '+event.origin+' is not the configured authorization_url.', type: 'bad'});
return false
}
// Bounced with an error
if (data.error !== undefined){
this.props.uiActions.createNotification({content: data.message, type: 'bad'});
// No errors? We're in!
} else {
this.props.lastfmActions.authorizationGranted(data)
this.props.lastfmActions.getMe()
}
// Turn off our authorizing switch
this.setState({authorizing: false});
}
startAuthorization(){
var self = this
this.setState({authorizing: true})
// Open an authentication request window
var url = this.props.authorization_url+'?action=authorize'
var popup = window.open(url,"popup","height=580,width=350");
popup.name = "LastfmAuthenticationWindow";
// Start timer to check our popup's state
var timer = setInterval(checkPopup, 1000);
function checkPopup(){
// Popup has been closed
if (typeof(popup) !== 'undefined' && popup){
if (popup.closed){
self.setState({authorizing: false})
clearInterval(timer);
}
// Popup does not exist, so must have been blocked
} else {
self.props.uiActions.createNotification({content: 'Popup blocked. Please allow popups and try again.', type: 'bad'});
self.setState({authorizing: false})
clearInterval(timer);
}
}
}
render(){
if (this.state.authorizing){
return (
<button className="working">
Authorizing...
</button>
)
} else if (this.props.authorized){
return (
<button className="destructive" onClick={() => this.props.lastfmActions.revokeAuthorization()}>Log out</button>
)
} else {
return (
<button className="primary" onClick={() => this.startAuthorization()}>Log in</button>
)
}
}
}
const mapStateToProps = (state, ownProps) => {
return {
authorization_url: state.lastfm.authorization_url,
authorized: state.lastfm.session,
authorizing: state.lastfm.authorizing
}
}
const mapDispatchToProps = (dispatch) => {
return {
uiActions: bindActionCreators(uiActions, dispatch),
lastfmActions: bindActionCreators(lastfmActions, dispatch)
}
}
export default connect(mapStateToProps, mapDispatchToProps)(LastfmAuthenticationFrame)

View File

@ -0,0 +1,62 @@
import React, { PropTypes } from 'react'
import { connect } from 'react-redux'
import { Link } from 'react-router'
import { createStore, bindActionCreators } from 'redux'
import FontAwesome from 'react-fontawesome'
import * as helpers from '../../helpers'
import * as uiActions from '../../services/ui/actions'
import * as lastfmActions from '../../services/lastfm/actions'
class FollowButton extends React.Component{
constructor(props){
super(props);
}
remove(){
this.props.lastfmActions.unloveTrack(this.props.uri, this.props.artist, this.props.track);
}
add(){
this.props.lastfmActions.loveTrack(this.props.uri, this.props.artist, this.props.track);
}
render(){
if (!this.props.uri){
return false;
}
var className = '';
// Inherit passed-down classes
if (this.props.className){
className += ' '+this.props.className;
}
if (!this.props.lastfm_authorized){
return <button className={className+' disabled'} onClick={e => this.props.uiActions.createNotification({content: 'You must authorize LastFM first', type: 'warning'})}>{this.props.addText}</button>
} else if (this.props.is_loved && this.props.is_loved !== "0"){
return <button className={className+' destructive'} onClick={e => this.remove()}>{this.props.removeText}</button>
} else {
return <button className={className} onClick={e => this.add()}>{this.props.addText}</button>
}
}
}
const mapStateToProps = (state, ownProps) => {
return {
load_queue: state.ui.load_queue,
lastfm_authorized: state.lastfm.session
}
}
const mapDispatchToProps = (dispatch) => {
return {
uiActions: bindActionCreators(uiActions, dispatch),
lastfmActions: bindActionCreators(lastfmActions, dispatch)
}
}
export default connect(mapStateToProps, mapDispatchToProps)(FollowButton)

View File

@ -0,0 +1,112 @@
import React, { PropTypes } from 'react';
export default class LatencyControl extends React.Component{
constructor(props){
super(props);
}
handleClick(e){
var slider = e.target;
if (slider.className != 'slider'){
slider = slider.parentElement;
}
var sliderX = e.clientX - slider.getBoundingClientRect().left;
var sliderWidth = slider.getBoundingClientRect().width;
// Clicked left half
if (sliderX < (sliderWidth/2)){
var percent = Math.round((sliderX / (sliderWidth/2) ) * 100);
// Invert our percentage
percent = 100 - percent;
var value = -(this.props.max * (percent / 100));
// Handle maximum value limits
if (value < -this.props.max){
value = -this.props.max;
}
// Second half
} else {
// Subtract half the slider's width from our click position
sliderX = sliderX - (sliderWidth/2);
var percent = Math.round((sliderX / (sliderWidth/2) ) * 100);
var value = this.props.max * (percent / 100);
// Handle maximum value limits
if (value > this.props.max){
value = this.props.max;
}
}
this.props.onChange(value);
}
handleWheel(e){
if (this.props.scrollWheel){
// Identify which direction we've scrolled (inverted)
// This is simplified and doesn't consider momentum as it varies wildly
// between browsers and devices
var direction = (e.deltaY > 0 ? -1 : 1)
var value = this.props.value;
value += direction * 5
if (value > this.props.max){
value = this.props.max;
} else if (value < -this.props.max){
value = -this.props.max;
}
this.props.onChange(value);
e.preventDefault();
}
}
render(){
// Zero, or positive value
if (this.props.value >= 0){
var value = this.props.value;
if (value > this.props.max){
value = this.props.max;
}
var percentage = Math.round((value / this.props.max) * 100 / 2);
var left = 50;
var width = percentage;
var negative = false;
// Negative value
// We reverse it to a positive for easier maths and style rules
} else {
var value = -this.props.value;
if (value < -this.props.max){
value = -this.props.max;
}
var percentage = Math.round((value / this.props.max) * 100 / 2);
var left = 50 - percentage;
var width = percentage;
var negative = true;
}
return (
<span className="latency-control" onWheel={e => this.handleWheel(e)}>
<div className="slider-wrapper">
<div className="slider horizontal" onClick={e => this.handleClick(e)}>
<div className="zero"></div>
<div className="track">
<div className={"progress "+(negative ? 'negative' : 'positive')} style={{ width: width+'%', left: left+'%' }}></div>
</div>
</div>
</div>
</span>
);
}
}

View File

@ -0,0 +1,178 @@
import React, { PropTypes } from 'react'
import { connect } from 'react-redux'
import { Link } from 'react-router'
import { bindActionCreators } from 'redux'
import FontAwesome from 'react-fontawesome'
import ProgressSlider from './ProgressSlider'
import VolumeControl from './VolumeControl'
import Dater from './../Dater'
import ArtistSentence from './../ArtistSentence'
import Thumbnail from './../Thumbnail'
import Icon from './../Icon'
import * as helpers from '../../helpers'
import * as uiActions from '../../services/ui/actions'
import * as coreActions from '../../services/core/actions'
import * as mopidyActions from '../../services/mopidy/actions'
class PlaybackControls extends React.Component{
constructor(props){
super(props)
this.state = {
expanded: false
}
}
renderPlayButton(){
var button = <a className="control play" onClick={() => this.props.mopidyActions.play()}><Icon name="play" /></a>
if (this.props.play_state == 'playing'){
button = <a className="control play" onClick={() => this.props.mopidyActions.pause()}><Icon name="pause" /></a>
}
return button;
}
renderConsumeButton(){
var button = <a className="control has-tooltip" onClick={() => this.props.mopidyActions.setConsume(true)}><FontAwesome name="fire" /><span className="tooltip">Consume</span></a>
if (this.props.consume){
button = <a className="control active has-tooltip" onClick={() => this.props.mopidyActions.setConsume(false)}><FontAwesome name="fire" /><span className="tooltip">Consume</span></a>
}
return button;
}
renderRandomButton(){
var button = <a className="control has-tooltip" onClick={() => this.props.mopidyActions.setRandom(true)}><FontAwesome name="random" /><span className="tooltip">Shuffle</span></a>
if (this.props.random){
button = <a className="control active has-tooltip" onClick={() => this.props.mopidyActions.setRandom(false)}><FontAwesome name="random" /><span className="tooltip">Shuffle</span></a>
}
return button;
}
renderRepeatButton(){
var button = <a className="control has-tooltip" onClick={() => this.props.mopidyActions.setRepeat(true)}><FontAwesome name="repeat" /><span className="tooltip">Repeat</span></a>
if (this.props.repeat){
button = <a className="control active has-tooltip" onClick={() => this.props.mopidyActions.setRepeat(false)}><FontAwesome name="repeat" /><span className="tooltip">Repeat</span></a>
}
return button;
}
handleThumbnailClick(e){
e.preventDefault();
this.props.uiActions.openModal('kiosk_mode');
}
render(){
var images = false
if (this.props.current_track && this.props.current_track.images){
images = this.props.current_track.images
}
return (
<div className={(this.state.expanded ? "expanded playback-controls" : "playback-controls")}>
{this.props.http_streaming_enabled && this.props.http_streaming_active && this.props.play_state == 'playing' ? <audio id="http-streamer" autoPlay preload="none">
<source src={this.props.http_streaming_url} type={"audio/"+this.props.http_streaming_encoding} />
</audio> : null}
{this.props.next_track && this.props.next_track.images ? <Thumbnail className="hide" size="large" images={this.props.next_track.images} /> : null}
<div className="current-track">
<div className="thumbnail-wrapper" onClick={e => this.handleThumbnailClick(e)}>
<Thumbnail size="small" images={images} />
</div>
<div className="title">
{ this.props.current_track ? this.props.current_track.name : <span>-</span> }
</div>
<div className="artist">
{ this.props.current_track ? <ArtistSentence artists={ this.props.current_track.artists } /> : <ArtistSentence /> }
</div>
</div>
<section className="playback">
<a className="control previous" onClick={() => this.props.mopidyActions.previous()}>
<Icon name="back" />
</a>
{ this.renderPlayButton() }
<a className="control stop" onClick={() => this.props.mopidyActions.stop()}>
<Icon name="stop" />
</a>
<a className="control next" onClick={() => this.props.mopidyActions.next()}>
<Icon name="skip" />
</a>
</section>
<section className="settings">
{this.renderConsumeButton()}
{this.renderRandomButton()}
{this.renderRepeatButton()}
</section>
<section className="progress">
<ProgressSlider />
<span className="current">{ this.props.time_position ? <Dater type="length" data={this.props.time_position} /> : '-' }</span>
<span className="total">{ this.props.current_track ? <Dater type="length" data={this.props.current_track.length} /> : '-' }</span>
</section>
<section className="volume">
<VolumeControl
scrollWheel
volume={this.props.volume}
mute={this.props.mute}
onVolumeChange={percent => this.props.mopidyActions.setVolume(percent)}
onMuteChange={mute => this.props.mopidyActions.setMute(mute)}
/>
</section>
<section className="triggers">
<a className="control expanded-controls" onClick={() => this.setState({expanded: !this.state.expanded})}>
{this.state.expanded ? <FontAwesome name="chevron-down" /> : <FontAwesome name="chevron-up" />}
</a>
<a className={"control sidebar-toggle"+(this.props.sidebar_open ? ' open' : '')} onClick={() => this.props.uiActions.toggleSidebar()}>
<FontAwesome className="open" name="bars" />
</a>
</section>
</div>
);
}
}
/**
* Export our component
*
* We also integrate our global store, using connect()
**/
const mapStateToProps = (state, ownProps) => {
return {
http_streaming_active: state.core.http_streaming_active,
http_streaming_enabled: state.core.http_streaming_enabled,
http_streaming_encoding: state.core.http_streaming_encoding,
http_streaming_url: state.core.http_streaming_url,
current_track: (state.core.current_track && state.core.tracks[state.core.current_track.uri] !== undefined ? state.core.tracks[state.core.current_track.uri] : null),
next_track: (state.core.next_track_uri && state.core.tracks[state.core.next_track_uri] !== undefined ? state.core.tracks[state.core.next_track_uri] : null),
radio_enabled: (state.ui.radio && state.ui.radio.enabled ? true : false),
play_state: state.mopidy.play_state,
time_position: state.mopidy.time_position,
consume: state.mopidy.consume,
repeat: state.mopidy.repeat,
random: state.mopidy.random,
volume: state.mopidy.volume,
mute: state.mopidy.mute,
sidebar_open: state.ui.sidebar_open
}
}
const mapDispatchToProps = (dispatch) => {
return {
coreActions: bindActionCreators(coreActions, dispatch),
uiActions: bindActionCreators(uiActions, dispatch),
mopidyActions: bindActionCreators(mopidyActions, dispatch)
}
}
export default connect(mapStateToProps, mapDispatchToProps)(PlaybackControls)

View File

@ -0,0 +1,70 @@
import React, { PropTypes } from 'react'
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import * as mopidyActions from '../../services/mopidy/actions'
class ProgressSlider extends React.Component{
constructor(props){
super(props);
}
handleClick(e){
var slider = e.target;
if (slider.className != 'slider' ) slider = slider.parentElement;
var sliderX = e.clientX - slider.getBoundingClientRect().left;
var sliderWidth = slider.getBoundingClientRect().width;
var percent = (sliderX / sliderWidth ).toFixed(2);
if (this.props.connected && this.props.current_track){
var destination_time = this.props.current_track.duration * percent;
this.props.mopidyActions.setTimePosition(destination_time);
this.setState({ animating: false });
}
}
render(){
var percent = 0;
if (this.props.connected && this.props.current_track){
percent = this.props.time_position / this.props.current_track.duration;
percent = percent * 100;
if (percent > 1000){
percent = 100;
}
}
return (
<div className={"progress slider horizontal "+this.props.play_state} onClick={ (e) => this.handleClick(e) } >
<div className="track">
<div className="progress" style={{ width: (percent)+'%' }}></div>
</div>
</div>
);
}
}
/**
* Export our component
*
* We also integrate our global store, using connect()
**/
const mapStateToProps = (state, ownProps) => {
return {
current_track: (state.core.current_track && state.core.tracks[state.core.current_track.uri] !== undefined ? state.core.tracks[state.core.current_track.uri] : null),
connected: state.mopidy.connected,
time_position: state.mopidy.time_position,
play_state: state.mopidy.play_state
}
}
const mapDispatchToProps = (dispatch) => {
return {
mopidyActions: bindActionCreators(mopidyActions, dispatch)
}
}
export default connect(mapStateToProps, mapDispatchToProps)(ProgressSlider)

View File

@ -0,0 +1,98 @@
import React, { PropTypes } from 'react'
import { connect } from 'react-redux'
import { hashHistory } from 'react-router'
import { createStore, bindActionCreators } from 'redux'
import FontAwesome from 'react-fontawesome'
import * as helpers from '../../helpers'
import * as uiActions from '../../services/ui/actions'
class SearchForm extends React.Component{
constructor(props){
super(props);
this.state = {
query: '',
in_focus: false
}
}
componentDidMount(){
if (this.props.query){
var query = this.props.query.replace("search:","");
this.setState({query: query});
}
}
componentWillReceiveProps(newProps){
if (newProps.query && newProps.query != this.state.query && !this.state.in_focus){
this.setState({query: newProps.query.replace("search:","")});
}
}
handleSubmit(e){
e.preventDefault()
// check for uri type matching
switch (helpers.uriType(this.state.query)){
case 'album':
hashHistory.push(global.baseURL+'album/'+encodeURIComponent(this.state.query))
break
case 'artist':
hashHistory.push(global.baseURL+'artist/'+encodeURIComponent(this.state.query))
break
case 'playlist':
hashHistory.push(global.baseURL+'playlist/'+encodeURIComponent(this.state.query))
break
default:
var available_views = ["artist","album","playlist","track"];
var view_defined = false;
var query = this.state.query;
for (var i = 0; i < available_views.length; i++){
if (query.startsWith(available_views[i]+':')){
view_defined = true;
}
}
if (!view_defined){
query = "all:"+query;
}
hashHistory.push(global.baseURL+'search/search:'+query);
break
}
return false
}
render(){
return (
<form className="search-form" onSubmit={e => this.handleSubmit(e)}>
<label>
<input
type="text"
placeholder="Search..."
onChange={ e => this.setState({ query: e.target.value }) }
onFocus={e => this.setState({in_focus: true})}
onBlur={e => this.setState({in_focus: false})}
value={ this.state.query } />
</label>
</form>
)
}
}
const mapDispatchToProps = (dispatch) => {
return {
uiActions: bindActionCreators(uiActions, dispatch)
}
}
export default connect(mapDispatchToProps)(SearchForm)

View File

@ -0,0 +1,139 @@
import React, { PropTypes } from 'react'
import { connect } from 'react-redux'
import { createStore, bindActionCreators } from 'redux'
import ReactGA from 'react-ga'
import FontAwesome from 'react-fontawesome'
import Thumbnail from '../Thumbnail'
import * as uiActions from '../../services/ui/actions'
import * as spotifyActions from '../../services/spotify/actions'
class SpotifyAuthenticationFrame extends React.Component{
constructor(props){
super(props);
this.state = {
authorizing: false
}
}
componentDidMount(){
let self = this;
// Listen for incoming messages from the authorization popup
window.addEventListener('message', function(event){
var data = JSON.parse(event.data);
// Only digest messages relevant to us
if (data.origin == 'auth_spotify'){
self.handleMessage(event, data);
}
}, false);
}
handleMessage(event, data){
// Only allow incoming data from our authorized authenticator proxy
var authorization_domain = this.props.authorization_url.substring(0,this.props.authorization_url.indexOf('/',8))
if (event.origin != authorization_domain){
this.props.uiActions.createNotification({content: 'Authorization failed. '+event.origin+' is not the configured authorization_url.', type: 'bad'});
return false
}
// Spotify bounced with an error
if (data.error !== undefined){
this.props.uiActions.createNotification({content: data.error, type: 'bad'});
// No errors? We're in!
} else {
this.props.spotifyActions.authorizationGranted(data)
this.props.spotifyActions.getMe()
}
// Turn off our authorizing switch
this.setState({authorizing: false})
}
startAuthorization(){
var self = this
this.setState({authorizing: true})
// Open an authentication request window (to spotify)
var url = this.props.authorization_url+'?action=authorize'
var scopes = [
'playlist-modify-private',
'playlist-modify-public',
'playlist-read-private',
'playlist-modify-private',
'user-library-read',
'user-library-modify',
'user-follow-modify',
'user-follow-read',
'user-read-email',
'user-top-read',
'user-read-currently-playing',
'user-read-playback-state',
'playlist-read-collaborative',
'ugc-image-upload' // playlist image uploading
]
var popup = window.open(url+'&scope='+scopes.join('%20'),"popup","height=580,width=350");
// Start timer to check our popup's state
var timer = setInterval(checkPopup, 1000);
function checkPopup(){
// Popup has been closed
if (typeof(popup) !== 'undefined' && popup){
if (popup.closed){
self.setState({authorizing: false})
clearInterval(timer);
}
// Popup does not exist, so must have been blocked
} else {
self.props.uiActions.createNotification({content: 'Popup blocked. Please allow popups and try again.', type: 'bad'});
self.setState({authorizing: false})
clearInterval(timer);
}
}
}
render(){
if (this.state.authorizing){
return (
<button className="working">
Authorizing...
</button>
)
} else if (this.props.authorized){
return (
<button className="destructive" onClick={() => this.props.spotifyActions.revokeAuthorization()}>Log out</button>
)
} else {
return (
<button className="primary" onClick={() => this.startAuthorization()}>Log in</button>
)
}
}
}
const mapStateToProps = (state, ownProps) => {
return {
authorization_url: state.spotify.authorization_url,
authorized: state.spotify.authorization,
authorizing: state.spotify.authorizing
}
}
const mapDispatchToProps = (dispatch) => {
return {
uiActions: bindActionCreators(uiActions, dispatch),
spotifyActions: bindActionCreators(spotifyActions, dispatch)
}
}
export default connect(mapStateToProps, mapDispatchToProps)(SpotifyAuthenticationFrame)

View File

@ -0,0 +1,48 @@
import React from 'react'
export default class TextField extends React.Component{
constructor(props){
super(props);
this.state = {
in_focus: false,
value: (this.props.value ? this.props.value : '')
}
}
componentWillReceiveProps(newProps){
if (!this.state.in_focus){
this.setState({value: newProps.value})
}
}
handleChange(e){
this.setState({value: e.target.value});
}
handleFocus(e){
this.setState({in_focus: true});
}
handleBlur(e){
this.setState({in_focus: false});
if (this.state.value !== this.props.value){
this.props.onChange(this.state.value);
}
}
render(){
return (
<input
className={this.props.className ? this.props.className : ''}
type="text"
onChange={e => this.handleChange(e)}
onFocus={e => this.handleFocus(e)}
onBlur={e => this.handleBlur(e)}
value={this.state.value}
/>
);
}
}

View File

@ -0,0 +1,86 @@
import React, { PropTypes } from 'react'
import FontAwesome from 'react-fontawesome'
export default class VolumeControl extends React.Component{
constructor(props){
super(props)
}
handleClick(e){
var slider = e.target;
if (slider.className != 'slider' ) slider = slider.parentElement;
var sliderX = e.clientX - slider.getBoundingClientRect().left;
var sliderWidth = slider.getBoundingClientRect().width;
var percent = Math.round((sliderX / sliderWidth ) * 100);
if (percent > 100){
percent = 100
} else if (percent < 0){
percent = 0
}
this.props.onVolumeChange(percent);
}
handleWheel(e){
if (this.props.scrollWheel){
// Identify which direction we've scrolled (inverted)
// This is simplified and doesn't consider momentum as it varies wildly
// between browsers and devices
var direction = (e.deltaY > 0 ? -1 : 1)
var percent = this.props.volume;
percent += direction * 5
if (percent > 100){
percent = 100
} else if (percent < 0){
percent = 0
}
this.props.onVolumeChange(percent);
e.preventDefault();
}
}
renderMuteButton(){
if (this.props.mute){
return (
<a className="control mute-control has-tooltip" onClick={() => this.props.onMuteChange(false)}>
<FontAwesome className="red-text" name="volume-off" />
<span className="tooltip">Unmute</span>
</a>
)
} else {
return (
<a className="control mute-control has-tooltip" onClick={() => this.props.onMuteChange(true)}>
<FontAwesome className="muted" name="volume-off" />
<span className="tooltip">Mute</span>
</a>
)
}
}
render(){
var className = "volume-control"
if (this.props.mute){
className += " muted"
}
return (
<span className={className} onWheel={e => this.handleWheel(e)}>
{this.renderMuteButton()}
<div className="slider-wrapper">
<div className="slider horizontal" onClick={e => this.handleClick(e)}>
<div className="track">
<div className="progress" style={{ width: this.props.volume+'%' }}></div>
</div>
</div>
</div>
</span>
);
}
}