Selaa lähdekoodia

[front] React-virtualized album list

Innocenty Enikeew 7 vuotta sitten
vanhempi
commit
cc53cc6170
6 muutettua tiedostoa jossa 429 lisäystä ja 279 poistoa
  1. 5 0
      front/src/AlbumList.css
  2. 141 2
      front/src/AlbumList.js
  3. 2 11
      front/src/App.css
  4. 2 266
      front/src/App.js
  5. 279 0
      front/src/Player.js
  6. BIN
      front/src/chad-logo-256.png

+ 5 - 0
front/src/AlbumList.css

@@ -2,9 +2,14 @@
   clear: both;
   padding-top: 15px;
 }
+.AlbumList h3 { margin: 10px 0}
+.AlbumList h4 { margin: 8px 0}
+.AlbumList .album { height: 80px; }
+
 .AlbumList img {
   width: 64px;
   height: 64px;
+  vertical-align: middle;
 }
 .AlbumList table td {
   width: 200px;

+ 141 - 2
front/src/AlbumList.js

@@ -1,7 +1,11 @@
 import React from 'react';
+import AutoSizer from 'react-virtualized/dist/commonjs/AutoSizer'
+import List from 'react-virtualized/dist/commonjs/List'
+import InfiniteLoader from 'react-virtualized/dist/commonjs/InfiniteLoader';
+
 import {formatDuration} from './utils.js';
 import logo from './logo.svg';
-import './AlbumList.css';
+import styles from './AlbumList.css';
 
 function mapAlbums(items) {
   var artists = [], curArtist = {}, curType = {};
@@ -20,7 +24,142 @@ function mapAlbums(items) {
   return artists;
 }
 
-export default function AlbumList(props) {
+const STATUS_LOADING = 1;
+
+export default class AlbumList extends React.PureComponent {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      loadedRowsMap: {},
+    };
+    this._isRowLoaded = this._isRowLoaded.bind(this);
+    this._loadMoreRows = this._loadMoreRows.bind(this);
+    this._rowRenderer = this._rowRenderer.bind(this);
+    this._rowHeight = this._rowHeight.bind(this);
+  }
+
+  render() {
+    const {rowCount} = this.props;
+
+    return (
+        <div className="AlbumList">
+      <h2>{this.props.title}</h2>
+
+        <InfiniteLoader
+          isRowLoaded={this._isRowLoaded}
+      loadMoreRows={this._loadMoreRows}
+      minimumBatchSize={15}
+          rowCount={rowCount}>
+        {({onRowsRendered, registerChild}) => (
+           <AutoSizer disableHeight>
+            {({width}) => (
+              <List
+                  ref={registerChild}
+              height={1000}
+            width={width}
+                  onRowsRendered={onRowsRendered}
+                  rowCount={rowCount}
+                  rowHeight={this._rowHeight}
+                  rowRenderer={this._rowRenderer}
+                />
+            )}
+          </AutoSizer>
+        )}
+      </InfiniteLoader>
+        </div>
+    )
+  }
+
+  _isRowLoaded({index}) {
+    const {loadedRowsMap} = this.state;
+    return !!loadedRowsMap[index]; // STATUS_LOADING or STATUS_LOADED
+  }
+
+  _loadMoreRows({startIndex, stopIndex}) {
+    console.log('_loadMoreRows', startIndex, stopIndex);
+    const {loadedRowsMap} = this.state;
+
+    for (var i = startIndex; i <= stopIndex; i++) {
+      loadedRowsMap[i] = STATUS_LOADING;
+    }
+
+    return this.props.loadMoreRows({offset: startIndex, limit: (stopIndex-startIndex+1)})
+      .then(rows => {
+        for (var i = startIndex; i <= stopIndex; i++) {
+          loadedRowsMap[i] = rows[i-startIndex];
+        }
+      })
+  }
+
+  _albumHeadings(index) {
+    let rows = this.state.loadedRowsMap;
+    if (index === 0 || rows[index-1] === STATUS_LOADING) {
+      return {showArtist: true, showType: true}
+    }
+
+    if (rows[index-1].artist !== rows[index].artist) {
+      return {showArtist: true, showType: true}
+    }
+
+    let showType = (rows[index-1].type !== rows[index].type);
+    return {showType};
+  }
+
+  _rowHeight({index}) {
+    const {loadedRowsMap} = this.state;
+    if (!loadedRowsMap[index]) {
+      return 0;
+    }
+    if (loadedRowsMap[index] === STATUS_LOADING) {
+      return 80;
+    }
+    let {showArtist, showType} = this._albumHeadings(index);
+    return 80 + (showArtist ? 42 : 0) + (showType ? 34 : 0);
+  }
+
+  _rowRenderer({index, key, style}) {
+    const {loadedRowsMap} = this.state;
+    let album = loadedRowsMap[index];
+    if (!album) {
+      return null;
+    }
+
+    let content;
+    if (album === STATUS_LOADING) {
+      content = (
+        <div className={styles.placeholder} />
+      );
+    } else {
+      let {showArtist, showType} = this._albumHeadings(index)
+      content = (
+          <div>
+          {showArtist && (<h3>{album.artist}</h3>)}
+        {showType && (<h4>{album.type}</h4>)}
+        <div className="album">
+          <img src={album.cover || logo} alt={album.album} />
+          <span>{album.year}</span>
+          <span>{album.album}</span>
+          <span>{album.publisher}</span>
+          <span>{album.country}</span>
+          <span>{album.genre}</span>
+          <span>{album.track_count}</span>
+          <span>{formatDuration(album.total_duration)}</span>
+          <span><button onClick={() => this.props.onPlayAlbum(album)}>play</button></span>
+          </div>
+          </div>
+      )
+    }
+
+    return (
+      <div className={styles.row} key={key} style={style}>
+        {content}
+      </div>
+    );
+  }
+}
+
+function AlbumListOld(props) {
   const { error, isLoaded, items } = props.state;
 
   var content;

+ 2 - 11
front/src/App.css

@@ -1,10 +1,6 @@
-.App {
-  text-align: center;
-}
-
 .App-logo {
-  animation: App-logo-spin infinite 20s linear;
-  height: 80px;
+  height: 64px;
+  padding: 8px;
   float: left;
 }
 
@@ -24,11 +20,6 @@
   font-size: large;
 }
 
-@keyframes App-logo-spin {
-  from { transform: rotate(0deg); }
-  to { transform: rotate(360deg); }
-}
-
 .Player {
   text-align: left;
   padding: 0 20px;

+ 2 - 266
front/src/App.js

@@ -3,274 +3,10 @@ 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 Player from './Player.js';
+import logo from './chad-logo-256.png';
 import './App.css';
 
-const fetchOpts = { credentials: 'same-origin' }
-
-class Player extends Component {
-  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.state = {
-      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: ''}
-    };
-    this.filterTimeout = null;
-  }
-  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;
-      }
-    }
-    return fetch(`/cat/album?${getQueryString(params)}`, fetchOpts)
-      .then(res => (res.ok ? res.json() : Promise.reject({message:res.statusText})))
-      .then(items => this.setState({album:{items, isLoaded: true}}))
-      .catch(error => this.setState({album:{error, items:[]}}))
-  }
-  componentDidMount() {
-    this.loadAlbums();
-  }
-  componentDidUpdate(prevProps, prevState) {
-    const {filters} = this.state;
-    if (filters !== prevState.filters) {
-      this.loadAlbums();
-    }
-    if (this.state.activeAlbum !== prevState.activeAlbum) {
-      this.fetchTracks(this.state.activeAlbum);
-    }
-    if (this.state.activeTrack !== prevState.activeTrack) {
-      if (this.state.activeTrack !== -1 && Array.isArray(this.state.tracks)) {
-        const track = this.state.tracks[this.state.activeTrack];
-        if (!track) {
-          console.log('Bad activeTrack', this.state.tracks, this.state.activeTrack);
-        } else {
-          this.setState({
-            sound: {
-              url: track.url,
-              position: 0,
-              playStatus: Sound.status.PLAYING
-            },
-            metadata: {
-              title: track.title,
-              artist: track.artist,
-              album: track.album,
-              cover: this.state.activeAlbum.cover
-            }
-          })
-        }
-      }
-    }
-  }
-  fetchTracks(album) {
-    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})
-  }
-  handleFilterChange = (e) => {
-    if (this.filterTimeout) {
-      clearInterval(this.filterTimeout);
-    }
-    const filter = e.target.value;
-    this.filterTimeout = setTimeout(() => this.loadAlbums(), 500);
-    this.setState({filter})
-  }
-  handleControlPrev = () => {
-    if (!Array.isArray(this.state.tracks)) {
-      return;
-    }
-    var activeTrack = this.state.activeTrack - 1
-    if (activeTrack < 0) {
-      activeTrack = -1;
-    }
-    this.setState({activeTrack: activeTrack})
-  }
-  handleControlPlayPause = () => {
-    const playStatus = (this.state.sound.playStatus === Sound.status.PLAYING ? Sound.status.PAUSED : Sound.status.PLAYING);
-    this.setState({sound: Object.assign({}, this.state.sound, {playStatus: playStatus})});
-  }
-  handleControlStop = () => {
-    this.setState({
-      sound: Object.assign({}, this.state.sound, {
-        playStatus: Sound.status.STOPPED,
-        position: 0}),
-      activeTrack: -1});
-  }
-  handleControlNext = () => {
-    if (!Array.isArray(this.state.tracks)) {
-      return;
-    }
-    var activeTrack = this.state.activeTrack + 1
-    if (activeTrack >= this.state.tracks.length) {
-      activeTrack = -1;
-    }
-    this.setState({activeTrack: activeTrack})
-  }
-  handleSoundError = (errorCode, description) => {
-    console.log('sound error', errorCode, description)
-    // try next track
-    this.handleControlNext();
-  }
-  handleSoundLoading = (sound) => {
-    // TODOconsole.log('sound loading', sound)
-  }
-  handleSoundPlaying = (sound) => {
-    this.setState({sound: Object.assign({}, this.state.sound, {
-      position: sound.position,
-      duration: sound.duration
-    })})
-  }
-  handleSoundFinished = () => {
-    console.log('sound finished');
-    this.setState({sound: Object.assign({}, this.state.sound, {playStatus: Sound.status.STOPPED})});
-    this.handleControlNext();
-  }
-  handleActivateTrack = (activeTrack) => {
-    this.setState({activeTrack: activeTrack})
-  }
-  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.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}
-          onNext={this.handleControlNext} />
-        <Playlist
-          tracks={this.state.tracks}
-          activeTrack={this.state.activeTrack}
-          onActivateTrack={this.handleActivateTrack} />
-        <AlbumList title="Albums" state={this.state.album} onPlayAlbum={this.handlePlayAlbum} />
-        <Sound {...this.state.sound}
-          onError={this.handleSoundError}
-          onLoading={this.handleSoundLoading}
-          onPlaying={this.handleSoundPlaying}
-          onFinishedPlaying={this.handleSoundFinished} />
-        <MediaSession
-          {...this.state.metadata}
-          onPlay={this.handleControlPlayPause}
-          onPause={this.handleControlPlayPause}
-          onPreviousTrack={this.handleControlPrev}
-          onNextTrack={this.handleControlNext}
-        />
-      </div>
-    );
-  }
-}
-
 class App extends Component {
   render() {
     return (

+ 279 - 0
front/src/Player.js

@@ -0,0 +1,279 @@
+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';
+
+const fetchOpts = { credentials: 'same-origin' }
+
+export default class Player extends Component {
+  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.state = {
+      albums:[],
+      albums_count: 0,
+      filters: {},
+      filter: '',
+      sound: {url: '', playStatus: Sound.status.STOPPED},
+      tracks: null,
+      activeTrack: -1,
+      activeAlbum: null,
+      metadata: {artist: '', album: '', title: '', cover: ''}
+    };
+    this.filterTimeout = null;
+  }
+  loadAlbums = ({size, offset, limit}) => {
+    const {filter, filters} = this.state
+    const params = {filter};
+    for (const {type} of this.selects) {
+      if (filters[type]) {
+        params[type] = filters[type].item;
+      }
+    }
+    if (offset) {
+      params.offset = offset;
+    }
+    if (limit) {
+      params.limit = limit;
+    }
+    return fetch(`/cat/album${size ? '/size' : ''}?${getQueryString(params)}`, fetchOpts)
+      .then(res => (res.ok ? res.json() : Promise.reject({message:res.statusText})))
+      .then(data => {
+        this.setState({[size ? 'albums_count' : 'albums']: data})
+        return data;
+      })
+      .catch(error => console.log(error))
+  }
+  componentDidMount() {
+    this.loadAlbums({size:true});
+  }
+  componentDidUpdate(prevProps, prevState) {
+    const {filters} = this.state;
+    if (filters !== prevState.filters) {
+      // TODO: reset
+      this.loadAlbums({size:true});
+    }
+    if (this.state.activeAlbum !== prevState.activeAlbum) {
+      this.fetchTracks(this.state.activeAlbum);
+    }
+    if (this.state.activeTrack !== prevState.activeTrack) {
+      if (this.state.activeTrack !== -1 && Array.isArray(this.state.tracks)) {
+        const track = this.state.tracks[this.state.activeTrack];
+        if (!track) {
+          console.log('Bad activeTrack', this.state.tracks, this.state.activeTrack);
+        } else {
+          this.setState({
+            sound: {
+              url: track.url,
+              position: 0,
+              playStatus: Sound.status.PLAYING
+            },
+            metadata: {
+              title: track.title,
+              artist: track.artist,
+              album: track.album,
+              cover: this.state.activeAlbum.cover
+            }
+          })
+        }
+      }
+    }
+  }
+  fetchTracks(album) {
+    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})
+  }
+  handleFilterChange = (e) => {
+    if (this.filterTimeout) {
+      clearInterval(this.filterTimeout);
+    }
+    const filter = e.target.value;
+    this.filterTimeout = setTimeout(() => {
+      // TODO: reset
+      this.loadAlbums({size:true})
+    }, 500);
+    this.setState({filter})
+  }
+  handleControlPrev = () => {
+    if (!Array.isArray(this.state.tracks)) {
+      return;
+    }
+    var activeTrack = this.state.activeTrack - 1
+    if (activeTrack < 0) {
+      activeTrack = -1;
+    }
+    this.setState({activeTrack: activeTrack})
+  }
+  handleControlPlayPause = () => {
+    const playStatus = (this.state.sound.playStatus === Sound.status.PLAYING ? Sound.status.PAUSED : Sound.status.PLAYING);
+    this.setState({sound: Object.assign({}, this.state.sound, {playStatus: playStatus})});
+  }
+  handleControlStop = () => {
+    this.setState({
+      sound: Object.assign({}, this.state.sound, {
+        playStatus: Sound.status.STOPPED,
+        position: 0}),
+      activeTrack: -1});
+  }
+  handleControlNext = () => {
+    if (!Array.isArray(this.state.tracks)) {
+      return;
+    }
+    var activeTrack = this.state.activeTrack + 1
+    if (activeTrack >= this.state.tracks.length) {
+      activeTrack = -1;
+    }
+    this.setState({activeTrack: activeTrack})
+  }
+  handleSoundError = (errorCode, description) => {
+    console.log('sound error', errorCode, description)
+    // try next track
+    this.handleControlNext();
+  }
+  handleSoundLoading = (sound) => {
+    // TODOconsole.log('sound loading', sound)
+  }
+  handleSoundPlaying = (sound) => {
+    this.setState({sound: Object.assign({}, this.state.sound, {
+      position: sound.position,
+      duration: sound.duration
+    })})
+  }
+  handleSoundFinished = () => {
+    console.log('sound finished');
+    this.setState({sound: Object.assign({}, this.state.sound, {playStatus: Sound.status.STOPPED})});
+    this.handleControlNext();
+  }
+  handleActivateTrack = (activeTrack) => {
+    this.setState({activeTrack: activeTrack})
+  }
+  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.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}
+          onNext={this.handleControlNext} />
+        <Playlist
+          tracks={this.state.tracks}
+          activeTrack={this.state.activeTrack}
+          onActivateTrack={this.handleActivateTrack} />
+        <AlbumList
+      title="Albums"
+      rowCount={this.state.albums_count}
+      items={this.state.albums}
+      loadMoreRows={this.loadAlbums}
+      onPlayAlbum={this.handlePlayAlbum} />
+        <Sound {...this.state.sound}
+          onError={this.handleSoundError}
+          onLoading={this.handleSoundLoading}
+          onPlaying={this.handleSoundPlaying}
+          onFinishedPlaying={this.handleSoundFinished} />
+        <MediaSession
+          {...this.state.metadata}
+          onPlay={this.handleControlPlayPause}
+          onPause={this.handleControlPlayPause}
+          onPreviousTrack={this.handleControlPrev}
+          onNextTrack={this.handleControlNext}
+        />
+      </div>
+    );
+  }
+}

BIN
front/src/chad-logo-256.png