|
|
@@ -1,228 +1,68 @@
|
|
|
+import "react-select/dist/react-select.css";
|
|
|
+import "react-virtualized/styles.css";
|
|
|
+import "react-virtualized-select/styles.css";
|
|
|
+
|
|
|
import React, { Component } from 'react';
|
|
|
+import Select from "react-virtualized-select";
|
|
|
import Sound from 'react-sound';
|
|
|
+import AlbumList from './AlbumList.js';
|
|
|
import MediaSession from './MediaSession.js';
|
|
|
+import Controls from './Controls.js';
|
|
|
+import Playlist from './Playlist.js';
|
|
|
+import {getQueryString} from './utils.js';
|
|
|
import logo from './logo.svg';
|
|
|
import './App.css';
|
|
|
|
|
|
-function CategoryFlatList(props) {
|
|
|
- const { error, isLoaded, items } = props.state;
|
|
|
- var content;
|
|
|
- if (error) {
|
|
|
- content = <div>Error: {error.message}</div>
|
|
|
- } else if (!isLoaded) {
|
|
|
- content = <div>Loading</div>
|
|
|
- } else {
|
|
|
- content = (
|
|
|
- <select size="10" defaultValue="" onChange={props.onChange}>
|
|
|
- <option value="">[ANY]</option>
|
|
|
- {items.map(item => (
|
|
|
- <option key={item.item} value={item.item}>{item.item} - {item.count}</option>
|
|
|
- ))}
|
|
|
- </select>
|
|
|
- );
|
|
|
- }
|
|
|
- return (
|
|
|
- <div className="category-list">
|
|
|
- <h2>{props.title}</h2>
|
|
|
- {content}
|
|
|
- </div>
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
-function formatDuration(duration) {
|
|
|
- duration = Math.floor(duration);
|
|
|
- var seconds = duration % 60;
|
|
|
- const minutes = Math.floor(duration / 60);
|
|
|
- if (seconds < 10) {
|
|
|
- seconds = '0' + seconds;
|
|
|
- }
|
|
|
- return minutes + ':' + seconds;
|
|
|
-}
|
|
|
-
|
|
|
-function mapAlbums(items) {
|
|
|
- var artists = [], curArtist = {}, curType = {};
|
|
|
- for (const album of items) {
|
|
|
- if (album.artist !== curArtist.name) {
|
|
|
- curArtist = {name: album.artist, types: []}
|
|
|
- curType = {}
|
|
|
- artists.push(curArtist)
|
|
|
- }
|
|
|
- if (album.type !== curType.type) {
|
|
|
- curType = {type: album.type, albums: []}
|
|
|
- curArtist.types.push(curType)
|
|
|
- }
|
|
|
- curType.albums.push(album);
|
|
|
- }
|
|
|
- return artists;
|
|
|
-}
|
|
|
-
|
|
|
-function AlbumList(props) {
|
|
|
- const { error, isLoaded, items } = props.state;
|
|
|
-
|
|
|
- var content;
|
|
|
- if (error) {
|
|
|
- content = <div>Error: {error.message}</div>
|
|
|
- } else if (!isLoaded) {
|
|
|
- content = <div>Loading</div>
|
|
|
- } else {
|
|
|
- const artists = mapAlbums(items);
|
|
|
-
|
|
|
- content = artists.map(artist => (
|
|
|
- <div key={artist.name}>
|
|
|
- <h3>{artist.name}</h3>
|
|
|
- {artist.types.map(type => (
|
|
|
- <div key={type.type}>
|
|
|
- <h4>{type.type}</h4>
|
|
|
- <table><tbody>
|
|
|
- {type.albums.map(album => (
|
|
|
- <tr key={album.id}>
|
|
|
- <td><img src={album.cover || logo} alt={album.album} /></td>
|
|
|
- <td>{album.year}</td>
|
|
|
- <td>{album.album}</td>
|
|
|
- <td>{album.publisher}</td>
|
|
|
- <td>{album.country}</td>
|
|
|
- <td>{album.genre}</td>
|
|
|
- <td>{album.track_count}</td>
|
|
|
- <td>{formatDuration(album.total_duration)}</td>
|
|
|
- <td><button onClick={() => props.onPlayAlbum(album)}>play</button></td>
|
|
|
- </tr>
|
|
|
- ))}
|
|
|
- </tbody></table>
|
|
|
- </div>
|
|
|
- ))}
|
|
|
- </div>
|
|
|
- ))
|
|
|
- }
|
|
|
- return (
|
|
|
- <div className="AlbumList">
|
|
|
- <h2>{props.title}</h2>
|
|
|
- {content}
|
|
|
- </div>
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
-function Controls(props) {
|
|
|
- const playPause = (props.playStatus === Sound.status.PLAYING ? 'pause' : 'play');
|
|
|
- const percent = (props.duration ? (100 * props.position / props.duration) : 0);
|
|
|
- const elapsed = (props.position ? formatDuration(props.position/1000) : '');
|
|
|
- const duration = (props.duration ? formatDuration(props.duration/1000) : '');
|
|
|
- return (
|
|
|
- <div className="Controls">
|
|
|
- <div className="Controls-progress">
|
|
|
- <div className="Controls-bar" style={{width:percent+'%'}} />
|
|
|
- <span className="Controls-elapsed">{elapsed}</span>
|
|
|
- <span className="Controls-duration">{duration}</span>
|
|
|
- </div>
|
|
|
- <div className="Controls-title">{props.title}</div>
|
|
|
- <div className="Controls-buttons">
|
|
|
- <button onClick={props.onPrev}>prev</button>
|
|
|
- <button onClick={props.onPlayPause}>{playPause}</button>
|
|
|
- <button onClick={props.onStop}>stop</button>
|
|
|
- <button onClick={props.onNext}>next</button>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
- );
|
|
|
-}
|
|
|
-
|
|
|
-function Playlist(props) {
|
|
|
- const {tracks} = props
|
|
|
- if (!tracks) {
|
|
|
- return null;
|
|
|
- }
|
|
|
- if (!Array.isArray(tracks)) {
|
|
|
- return <div className="Playlist-error">{tracks}</div>;
|
|
|
- }
|
|
|
- return (
|
|
|
- <table className="Playlist"><tbody>
|
|
|
- {tracks.map((track, i) => (
|
|
|
- <tr key={i} className={ i === props.activeTrack ? "Playlist-active" : "" }>
|
|
|
- <td>{track.no}</td>
|
|
|
- <td>{track.title}</td>
|
|
|
- <td>{track.artist}</td>
|
|
|
- <td>{track.year}</td>
|
|
|
- <td>{track.album}</td>
|
|
|
- <td>{track.bit_rate} kbps</td>
|
|
|
- <td>{formatDuration(track.duration)}</td>
|
|
|
- <td><button onClick={() => props.onActivateTrack(i)}>play</button></td>
|
|
|
- </tr>))}
|
|
|
- </tbody></table>
|
|
|
- )
|
|
|
-}
|
|
|
+const fetchOpts = { credentials: 'same-origin' }
|
|
|
|
|
|
class Player extends Component {
|
|
|
- stateLoading = {
|
|
|
- error: null,
|
|
|
- isLoaded: false,
|
|
|
- items: []
|
|
|
- };
|
|
|
+ selects = [{type: 'artist', title: 'Artists'},
|
|
|
+ {type: 'year', title: 'Years'},
|
|
|
+ {type: 'genre', title: 'Genres'},
|
|
|
+ {type: 'publisher', title: 'Labels'},
|
|
|
+ {type: 'country', title: 'Countries'},
|
|
|
+ {type: 'type', title: 'Album types'},
|
|
|
+ {type: 'status', title: 'Album statuses'}];
|
|
|
constructor(props) {
|
|
|
super(props);
|
|
|
- this.categories = [{type: 'artist', title: 'Artists'},
|
|
|
- {type: 'year', title: 'Years'},
|
|
|
- {type: 'genre', title: 'Genres'},
|
|
|
- {type: 'publisher', title: 'Labels'},
|
|
|
- {type: 'country', title: 'Countries'},
|
|
|
- {type: 'type', title: 'Album types'},
|
|
|
- {type: 'status', title: 'Album statuses'}];
|
|
|
this.state = {
|
|
|
- album: Object.assign({}, this.stateLoading),
|
|
|
+ album: {
|
|
|
+ error: null,
|
|
|
+ isLoaded: false,
|
|
|
+ items: []
|
|
|
+ },
|
|
|
+ filters: {},
|
|
|
+ filter: '',
|
|
|
sound: {url: '', playStatus: Sound.status.STOPPED},
|
|
|
tracks: null,
|
|
|
activeTrack: -1,
|
|
|
activeAlbum: null,
|
|
|
metadata: {artist: '', album: '', title: '', cover: ''}
|
|
|
};
|
|
|
- for (var {type} of this.categories) {
|
|
|
- this.state[type] = Object.assign({}, this.stateLoading);
|
|
|
- }
|
|
|
this.filterTimeout = null;
|
|
|
}
|
|
|
- fetchCategory(cat) {
|
|
|
- const {restrictions, filter} = this.state;
|
|
|
-
|
|
|
- function getQueryString(params) {
|
|
|
- var esc = encodeURIComponent;
|
|
|
- return Object.keys(params)
|
|
|
- .map(k => esc(k) + '=' + esc(params[k]))
|
|
|
- .join('&');
|
|
|
- }
|
|
|
- var params = Object.assign({}, restrictions, {offset:0, limit:100});
|
|
|
- if (cat === 'album' && filter) {
|
|
|
- params.filter = filter;
|
|
|
+ loadAlbums() {
|
|
|
+ const {filter, filters} = this.state
|
|
|
+ const page = 1
|
|
|
+ const pageSize = 30
|
|
|
+ const params = {filter, offset: (page - 1) * pageSize, limit: pageSize};
|
|
|
+ for (const {type} of this.selects) {
|
|
|
+ if (filters[type]) {
|
|
|
+ params[type] = filters[type].item;
|
|
|
+ }
|
|
|
}
|
|
|
- const qs = getQueryString(params);
|
|
|
-
|
|
|
- fetch('/cat/' + cat + (qs ? ('?' + qs) : ''))
|
|
|
+ return fetch(`/cat/album?${getQueryString(params)}`, fetchOpts)
|
|
|
.then(res => (res.ok ? res.json() : Promise.reject({message:res.statusText})))
|
|
|
- .then(result => {
|
|
|
- this.setState({[cat]: {
|
|
|
- isLoaded: true,
|
|
|
- items: result
|
|
|
- }});
|
|
|
- })
|
|
|
- .catch(error => {
|
|
|
- this.setState({[cat]: {
|
|
|
- isLoaded: true,
|
|
|
- error: error
|
|
|
- }});
|
|
|
- });
|
|
|
- this.setState({[cat]: Object.assign({}, this.stateLoading)});
|
|
|
- }
|
|
|
- fetchAll() {
|
|
|
- const {restrictions} = this.state;
|
|
|
- console.log(restrictions);
|
|
|
- this.categories.map(({type}) => !restrictions[type] && this.fetchCategory(type))
|
|
|
- this.fetchCategory('album');
|
|
|
+ .then(items => this.setState({album:{items, isLoaded: true}}))
|
|
|
+ .catch(error => this.setState({album:{error, items:[]}}))
|
|
|
}
|
|
|
componentDidMount() {
|
|
|
- this.setState({restrictions: {}});
|
|
|
+ this.loadAlbums();
|
|
|
}
|
|
|
componentDidUpdate(prevProps, prevState) {
|
|
|
- if (this.state.restrictions !== prevState.restrictions) {
|
|
|
- // Reload category filters on restriction changes
|
|
|
- this.fetchAll();
|
|
|
- } else if (this.state.filter !== prevState.filter) {
|
|
|
- this.fetchCategory('album');
|
|
|
+ const {filters} = this.state;
|
|
|
+ if (filters !== prevState.filters) {
|
|
|
+ this.loadAlbums();
|
|
|
}
|
|
|
if (this.state.activeAlbum !== prevState.activeAlbum) {
|
|
|
this.fetchTracks(this.state.activeAlbum);
|
|
|
@@ -251,28 +91,19 @@ class Player extends Component {
|
|
|
}
|
|
|
}
|
|
|
fetchTracks(album) {
|
|
|
- fetch(`/album/${album.id}/tracks`)
|
|
|
+ fetch(`/album/${album.id}/tracks`, fetchOpts)
|
|
|
.then(res => (res.ok ? res.json() : Promise.reject({message:res.statusText})))
|
|
|
.then(result => this.setState({tracks: result, activeTrack: 0}))
|
|
|
.catch(error => this.setState({tracks: error.message}));
|
|
|
this.setState({tracks: "Loading", activeTrack: -1})
|
|
|
}
|
|
|
- handleCategoryChange(type, value) {
|
|
|
- console.log(type, value);
|
|
|
- var restrictions = Object.assign({}, this.state.restrictions);
|
|
|
- if (value) {
|
|
|
- restrictions[type] = value;
|
|
|
- } else {
|
|
|
- delete restrictions[type];
|
|
|
- }
|
|
|
- this.setState({restrictions: restrictions});
|
|
|
- }
|
|
|
handleFilterChange = (e) => {
|
|
|
if (this.filterTimeout) {
|
|
|
clearInterval(this.filterTimeout);
|
|
|
}
|
|
|
- const value = e.target.value;
|
|
|
- this.filterTimeout = setTimeout(() => this.setState({filter:value}), 500);
|
|
|
+ const filter = e.target.value;
|
|
|
+ this.filterTimeout = setTimeout(() => this.loadAlbums(), 500);
|
|
|
+ this.setState({filter})
|
|
|
}
|
|
|
handleControlPrev = () => {
|
|
|
if (!Array.isArray(this.state.tracks)) {
|
|
|
@@ -330,16 +161,90 @@ class Player extends Component {
|
|
|
handlePlayAlbum = (album) => {
|
|
|
this.setState({activeAlbum: album})
|
|
|
}
|
|
|
+ handleLoadOptions(cat, q, page) {
|
|
|
+ const pageSize = 100
|
|
|
+ const params = {filter:q}
|
|
|
+ params.offset = (page - 1) * pageSize;
|
|
|
+ params.limit = pageSize;
|
|
|
+ return fetch(`/cat/${cat}?${getQueryString(params)}`, fetchOpts)
|
|
|
+ .then(res => (res.ok ? res.json() : Promise.reject({message:res.statusText})))
|
|
|
+ .then(options => ({options}))
|
|
|
+ }
|
|
|
+ handleSelectChange(cat, option) {
|
|
|
+ const filters = Object.assign({}, this.state.filters, {[cat]: option});
|
|
|
+ this.setState({filters, filter: ''})
|
|
|
+ }
|
|
|
+
|
|
|
+ _optionRenderer ({ focusedOption, focusOption, key, labelKey, option, selectValue, style, valueArray }) {
|
|
|
+ const className = ['VirtualizedSelectOption']
|
|
|
+
|
|
|
+ if (option === focusedOption) {
|
|
|
+ className.push('VirtualizedSelectFocusedOption')
|
|
|
+ }
|
|
|
+
|
|
|
+ if (option.disabled) {
|
|
|
+ className.push('VirtualizedSelectDisabledOption')
|
|
|
+ }
|
|
|
+
|
|
|
+ if (valueArray && valueArray.indexOf(option) >= 0) {
|
|
|
+ className.push('VirtualizedSelectSelectedOption')
|
|
|
+ }
|
|
|
+
|
|
|
+ if (option.className) {
|
|
|
+ className.push(option.className)
|
|
|
+ }
|
|
|
+
|
|
|
+ const events = option.disabled
|
|
|
+ ? {}
|
|
|
+ : {
|
|
|
+ onClick: () => selectValue(option),
|
|
|
+ onMouseEnter: () => focusOption(option)
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div
|
|
|
+ className={className.join(' ')}
|
|
|
+ key={key}
|
|
|
+ style={style}
|
|
|
+ title={option.title}
|
|
|
+ {...events}
|
|
|
+ >
|
|
|
+ {option.item} - {option.count}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+ }
|
|
|
render() {
|
|
|
return (
|
|
|
<div className="Player">
|
|
|
<div className="PlayerFilters">
|
|
|
- {this.categories.map(({type, title}) => (
|
|
|
- <CategoryFlatList key={type} title={title} state={this.state[type]} onChange={e => this.handleCategoryChange(type, e.target.value)}/>
|
|
|
- ))}
|
|
|
- </div><div style={{clear:'both', paddingBottom:'20px'}}/>
|
|
|
- <input type="text" placeholder="Search albums" onChange={this.handleFilterChange} size={150} />
|
|
|
- <Controls {...this.state.sound}
|
|
|
+ {this.selects.map(({type, title}) => (
|
|
|
+ <div className="section" key={type}>
|
|
|
+ <h3>{title}</h3>
|
|
|
+ <Select
|
|
|
+ async
|
|
|
+ pagination
|
|
|
+ autoload={true}
|
|
|
+ optionRenderer={this._optionRenderer}
|
|
|
+ loadOptions={(q,p) => this.handleLoadOptions(type, q, p)}
|
|
|
+ onChange={o => this.handleSelectChange(type, o)}
|
|
|
+ labelKey="item"
|
|
|
+ valueKey="item"
|
|
|
+ value={this.state.filters[type]}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ <div className="section">
|
|
|
+ <h3>Search</h3>
|
|
|
+ <input className="Filter"
|
|
|
+ type="text"
|
|
|
+ value={this.state.filter}
|
|
|
+ placeholder="Search albums"
|
|
|
+ onChange={this.handleFilterChange} />
|
|
|
+ </div>
|
|
|
+ </div><br style={{clear:'both'}} />
|
|
|
+ <Controls
|
|
|
+ visible={true}
|
|
|
+ {...this.state.sound}
|
|
|
onPrev={this.handleControlPrev}
|
|
|
onPlayPause={this.handleControlPlayPause}
|
|
|
onStop={this.handleControlStop}
|