Innocenty Enikeew 7 년 전
부모
커밋
4310926250
14개의 변경된 파일863개의 추가작업 그리고 373개의 파일을 삭제
  1. 418 0
      front/package-lock.json
  2. 5 1
      front/package.json
  3. 1 1
      front/public/index.html
  4. 1 0
      front/src/AlbumList.js
  5. 28 43
      front/src/App.css
  6. 129 8
      front/src/App.js
  7. 0 29
      front/src/Controls.js
  8. 4 1
      front/src/MediaSession.js
  9. 51 0
      front/src/Player.css
  10. 38 278
      front/src/Player.js
  11. 16 10
      front/src/Playlist.js
  12. 163 0
      front/src/Selector.js
  13. 6 1
      front/src/index.css
  14. 3 1
      front/src/index.js

+ 418 - 0
front/package-lock.json

@@ -4,6 +4,24 @@
   "lockfileVersion": 1,
   "requires": true,
   "dependencies": {
+    "@types/jss": {
+      "version": "9.3.3",
+      "resolved": "https://registry.npmjs.org/@types/jss/-/jss-9.3.3.tgz",
+      "integrity": "sha512-EK+f8Gnu8ybRXA4jKzjQlyEddV2SVL20Cfedhxk/PRMhoh01OC3o958L/dO9klKAwMG/YXH1KIUfk5VgVLslQA=="
+    },
+    "@types/react": {
+      "version": "16.3.1",
+      "resolved": "https://registry.npmjs.org/@types/react/-/react-16.3.1.tgz",
+      "integrity": "sha512-bDni2+bq90eBYPAUbElpx5Lm0Cgbyo7oqrxIjrOSnyEb2QBjmLWKdfr1hYNhlamA5x4oKRZETED59q4/oe7ZBQ=="
+    },
+    "@types/react-transition-group": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-2.0.7.tgz",
+      "integrity": "sha512-aTbd37E2XJ5Zi/lRrXo74RMhZikS/r5a06EStXEdapy4pqzvPrdY9BpWGNSpnyp8oNaggL0duljNdC8T0dRIUA==",
+      "requires": {
+        "@types/react": "16.3.1"
+      }
+    },
     "abab": {
       "version": "1.0.4",
       "resolved": "https://registry.npmjs.org/abab/-/abab-1.0.4.tgz",
@@ -1424,6 +1442,11 @@
         }
       }
     },
+    "brcast": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/brcast/-/brcast-3.0.1.tgz",
+      "integrity": "sha512-eI3yqf9YEqyGl9PCNTR46MGvDylGtaHjalcz6Q3fAPnP/PhpKkkve52vFdfGpwp4VUvK6LUr4TQN+2stCrEwTg=="
+    },
     "brorand": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
@@ -1675,6 +1698,11 @@
         "lazy-cache": "1.0.4"
       }
     },
+    "chain-function": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/chain-function/-/chain-function-1.0.0.tgz",
+      "integrity": "sha1-DUqzfn4Y6tC9xHuSB2QRjOWHM9w="
+    },
     "chalk": {
       "version": "1.1.3",
       "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz",
@@ -1699,6 +1727,11 @@
         }
       }
     },
+    "change-emitter": {
+      "version": "0.1.6",
+      "resolved": "https://registry.npmjs.org/change-emitter/-/change-emitter-0.1.6.tgz",
+      "integrity": "sha1-6LL+PX8at9aaMhma/5HqaTFAlRU="
+    },
     "chardet": {
       "version": "0.4.2",
       "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.4.2.tgz",
@@ -2347,6 +2380,14 @@
         }
       }
     },
+    "css-vendor": {
+      "version": "0.3.8",
+      "resolved": "https://registry.npmjs.org/css-vendor/-/css-vendor-0.3.8.tgz",
+      "integrity": "sha1-ZCHP0wNM5mT+dnOXL9ARn8KJQfo=",
+      "requires": {
+        "is-in-browser": "1.1.3"
+      }
+    },
     "css-what": {
       "version": "2.1.0",
       "resolved": "https://registry.npmjs.org/css-what/-/css-what-2.1.0.tgz",
@@ -2545,6 +2586,11 @@
       "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz",
       "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ="
     },
+    "deepmerge": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.1.0.tgz",
+      "integrity": "sha512-Q89Z26KAfA3lpPGhbF6XMfYAm3jIV3avViy6KOJ2JLzFbeWHOvPQUu5aSJIWXap3gDZC2y1eF5HXEPI2wGqgvw=="
+    },
     "default-require-extensions": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-1.0.0.tgz",
@@ -2725,6 +2771,11 @@
         "urijs": "1.19.1"
       }
     },
+    "dom-walk": {
+      "version": "0.1.1",
+      "resolved": "https://registry.npmjs.org/dom-walk/-/dom-walk-0.1.1.tgz",
+      "integrity": "sha1-ZyIm3HTI95mtNTB9+TaroRrNYBg="
+    },
     "domain-browser": {
       "version": "1.2.0",
       "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz",
@@ -4735,6 +4786,22 @@
         "is-glob": "2.0.1"
       }
     },
+    "global": {
+      "version": "4.3.2",
+      "resolved": "https://registry.npmjs.org/global/-/global-4.3.2.tgz",
+      "integrity": "sha1-52mJJopsdMOJCLEwWxD8DjlOnQ8=",
+      "requires": {
+        "min-document": "2.19.0",
+        "process": "0.5.2"
+      },
+      "dependencies": {
+        "process": {
+          "version": "0.5.2",
+          "resolved": "https://registry.npmjs.org/process/-/process-0.5.2.tgz",
+          "integrity": "sha1-FjjYqONML0QKkduVq5rrZ3/Bhc8="
+        }
+      }
+    },
     "global-dirs": {
       "version": "0.1.1",
       "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz",
@@ -4978,6 +5045,18 @@
       "resolved": "https://registry.npmjs.org/he/-/he-1.1.1.tgz",
       "integrity": "sha1-k0EP0hsAlzUVH4howvJx80J+I/0="
     },
+    "history": {
+      "version": "4.7.2",
+      "resolved": "https://registry.npmjs.org/history/-/history-4.7.2.tgz",
+      "integrity": "sha512-1zkBRWW6XweO0NBcjiphtVJVsIQ+SXF29z9DVkceeaSLVMFXHool+fdCZD4spDCfZJCILPILc3bm7Bc+HRi0nA==",
+      "requires": {
+        "invariant": "2.2.4",
+        "loose-envify": "1.3.1",
+        "resolve-pathname": "2.2.0",
+        "value-equal": "0.4.0",
+        "warning": "3.0.0"
+      }
+    },
     "hmac-drbg": {
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz",
@@ -4993,6 +5072,11 @@
       "resolved": "https://registry.npmjs.org/hoek/-/hoek-4.2.1.tgz",
       "integrity": "sha512-QLg82fGkfnJ/4iy1xZ81/9SIJiq1NGFUMGs6ParyjBZr6jW2Ufj/snDqTHixNlHdPNwN2RLVD0Pi3igeK9+JfA=="
     },
+    "hoist-non-react-statics": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.0.tgz",
+      "integrity": "sha512-6Bl6XsDT1ntE0lHbIhr4Kp2PGcleGZ66qu5Jqk8lc0Xc/IeG6gVLmwUGs/K0Us+L8VWoKgj0uWdPMataOsm31w=="
+    },
     "home-or-tmp": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/home-or-tmp/-/home-or-tmp-2.0.0.tgz",
@@ -5299,6 +5383,11 @@
       "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz",
       "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM="
     },
+    "hyphenate-style-name": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.2.tgz",
+      "integrity": "sha1-MRYKNpMK2vH8BMYHT360FGXU7Es="
+    },
     "iconv-lite": {
       "version": "0.4.19",
       "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.19.tgz",
@@ -5581,6 +5670,11 @@
       "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
       "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8="
     },
+    "is-function": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/is-function/-/is-function-1.0.1.tgz",
+      "integrity": "sha1-Es+5i2W1fdPRk6MSH19uL0N2ArU="
+    },
     "is-glob": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz",
@@ -5589,6 +5683,11 @@
         "is-extglob": "1.0.0"
       }
     },
+    "is-in-browser": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/is-in-browser/-/is-in-browser-1.1.3.tgz",
+      "integrity": "sha1-Vv9NtoOgeMYILrldrX3GLh0E+DU="
+    },
     "is-installed-globally": {
       "version": "0.1.0",
       "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.1.0.tgz",
@@ -6584,11 +6683,111 @@
         "verror": "1.10.0"
       }
     },
+    "jss": {
+      "version": "9.8.1",
+      "resolved": "https://registry.npmjs.org/jss/-/jss-9.8.1.tgz",
+      "integrity": "sha512-a9dXInEPTRmdSmzw3LNhbAwdQVZgCRmFU7dFzrpLTMAcdolHXNamhxQ6J+PNIqUtWa9yRbZIzWX6aUlI55LZ/A==",
+      "requires": {
+        "is-in-browser": "1.1.3",
+        "symbol-observable": "1.2.0",
+        "warning": "3.0.0"
+      }
+    },
+    "jss-camel-case": {
+      "version": "6.1.0",
+      "resolved": "https://registry.npmjs.org/jss-camel-case/-/jss-camel-case-6.1.0.tgz",
+      "integrity": "sha512-HPF2Q7wmNW1t79mCqSeU2vdd/vFFGpkazwvfHMOhPlMgXrJDzdj9viA2SaHk9ZbD5pfL63a8ylp4++irYbbzMQ==",
+      "requires": {
+        "hyphenate-style-name": "1.0.2"
+      }
+    },
+    "jss-compose": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/jss-compose/-/jss-compose-5.0.0.tgz",
+      "integrity": "sha512-YofRYuiA0+VbeOw0VjgkyO380sA4+TWDrW52nSluD9n+1FWOlDzNbgpZ/Sb3Y46+DcAbOS21W5jo6SAqUEiuwA==",
+      "requires": {
+        "warning": "3.0.0"
+      }
+    },
+    "jss-default-unit": {
+      "version": "8.0.2",
+      "resolved": "https://registry.npmjs.org/jss-default-unit/-/jss-default-unit-8.0.2.tgz",
+      "integrity": "sha512-WxNHrF/18CdoAGw2H0FqOEvJdREXVXLazn7PQYU7V6/BWkCV0GkmWsppNiExdw8dP4TU1ma1dT9zBNJ95feLmg=="
+    },
+    "jss-expand": {
+      "version": "5.1.0",
+      "resolved": "https://registry.npmjs.org/jss-expand/-/jss-expand-5.1.0.tgz",
+      "integrity": "sha512-WTxmNipgj0V8kr8gc8Gc6Et7uQZH60H7FFNG9zZHjR6TPJoj7TDK+/EBxwRHtCRQD4B8RTwoa7MyEKD4ReKfXw=="
+    },
+    "jss-extend": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/jss-extend/-/jss-extend-6.2.0.tgz",
+      "integrity": "sha512-YszrmcB6o9HOsKPszK7NeDBNNjVyiW864jfoiHoMlgMIg2qlxKw70axZHqgczXHDcoyi/0/ikP1XaHDPRvYtEA==",
+      "requires": {
+        "warning": "3.0.0"
+      }
+    },
+    "jss-global": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/jss-global/-/jss-global-3.0.0.tgz",
+      "integrity": "sha512-wxYn7vL+TImyQYGAfdplg7yaxnPQ9RaXY/cIA8hawaVnmmWxDHzBK32u1y+RAvWboa3lW83ya3nVZ/C+jyjZ5Q=="
+    },
+    "jss-nested": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/jss-nested/-/jss-nested-6.0.1.tgz",
+      "integrity": "sha512-rn964TralHOZxoyEgeq3hXY8hyuCElnvQoVrQwKHVmu55VRDd6IqExAx9be5HgK0yN/+hQdgAXQl/GUrBbbSTA==",
+      "requires": {
+        "warning": "3.0.0"
+      }
+    },
+    "jss-preset-default": {
+      "version": "4.3.0",
+      "resolved": "https://registry.npmjs.org/jss-preset-default/-/jss-preset-default-4.3.0.tgz",
+      "integrity": "sha512-3VqMmR07OkiGyVPHfke/sjR33kSyRVjIE/3+bGgJ9Pp1jMIAPIDDY3h3wfEwa97DFV25SncTrNjjIgBFVCb4BA==",
+      "requires": {
+        "jss-camel-case": "6.1.0",
+        "jss-compose": "5.0.0",
+        "jss-default-unit": "8.0.2",
+        "jss-expand": "5.1.0",
+        "jss-extend": "6.2.0",
+        "jss-global": "3.0.0",
+        "jss-nested": "6.0.1",
+        "jss-props-sort": "6.0.0",
+        "jss-template": "1.0.1",
+        "jss-vendor-prefixer": "7.0.0"
+      }
+    },
+    "jss-props-sort": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmjs.org/jss-props-sort/-/jss-props-sort-6.0.0.tgz",
+      "integrity": "sha512-E89UDcrphmI0LzmvYk25Hp4aE5ZBsXqMWlkFXS0EtPkunJkRr+WXdCNYbXbksIPnKlBenGB9OxzQY+mVc70S+g=="
+    },
+    "jss-template": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/jss-template/-/jss-template-1.0.1.tgz",
+      "integrity": "sha512-m5BqEWha17fmIVXm1z8xbJhY6GFJxNB9H68GVnCWPyGYfxiAgY9WTQyvDAVj+pYRgrXSOfN5V1T4+SzN1sJTeg==",
+      "requires": {
+        "warning": "3.0.0"
+      }
+    },
+    "jss-vendor-prefixer": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/jss-vendor-prefixer/-/jss-vendor-prefixer-7.0.0.tgz",
+      "integrity": "sha512-Agd+FKmvsI0HLcYXkvy8GYOw3AAASBUpsmIRvVQheps+JWaN892uFOInTr0DRydwaD91vSSUCU4NssschvF7MA==",
+      "requires": {
+        "css-vendor": "0.3.8"
+      }
+    },
     "jsx-ast-utils": {
       "version": "1.4.1",
       "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-1.4.1.tgz",
       "integrity": "sha1-OGchPo3Xm/Ho8jAMDPwe+xgsDfE="
     },
+    "keycode": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.0.tgz",
+      "integrity": "sha1-PQr1bce4uOXLqNCpfxByBO7CKwQ="
+    },
     "killable": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/killable/-/killable-1.0.0.tgz",
@@ -6868,6 +7067,49 @@
         "object-visit": "1.0.1"
       }
     },
+    "material-ui": {
+      "version": "1.0.0-beta.40",
+      "resolved": "https://registry.npmjs.org/material-ui/-/material-ui-1.0.0-beta.40.tgz",
+      "integrity": "sha512-2hVbeKtAw3Wk2o2EXPMs18K+fEz+oPJrO4AN0JiwjLAiRYsiRIdjeuSx7LWU3z+PuO6+8hh/rrvCcVWwOPV9Gw==",
+      "requires": {
+        "@types/jss": "9.3.3",
+        "@types/react-transition-group": "2.0.7",
+        "babel-runtime": "6.26.0",
+        "brcast": "3.0.1",
+        "classnames": "2.2.5",
+        "deepmerge": "2.1.0",
+        "dom-helpers": "3.3.1",
+        "hoist-non-react-statics": "2.5.0",
+        "jss": "9.8.1",
+        "jss-camel-case": "6.1.0",
+        "jss-default-unit": "8.0.2",
+        "jss-global": "3.0.0",
+        "jss-nested": "6.0.1",
+        "jss-props-sort": "6.0.0",
+        "jss-vendor-prefixer": "7.0.0",
+        "keycode": "2.2.0",
+        "lodash": "4.17.5",
+        "normalize-scroll-left": "0.1.2",
+        "prop-types": "15.6.1",
+        "react-event-listener": "0.5.3",
+        "react-jss": "8.4.0",
+        "react-lifecycles-compat": "1.1.1",
+        "react-popper": "0.8.3",
+        "react-scrollbar-size": "2.1.0",
+        "react-transition-group": "2.3.0",
+        "recompose": "0.26.0",
+        "scroll": "2.0.3",
+        "warning": "3.0.0"
+      }
+    },
+    "material-ui-icons": {
+      "version": "1.0.0-beta.36",
+      "resolved": "https://registry.npmjs.org/material-ui-icons/-/material-ui-icons-1.0.0-beta.36.tgz",
+      "integrity": "sha512-7rS6b2EV5QXCB/gTi/Ac9Wbxd+h9EZv1Td3rLLJe4IER8mVHRgdqZccB3EsjW2DrJ7opdY1+8X3/vyrS7CQNpg==",
+      "requires": {
+        "recompose": "0.26.0"
+      }
+    },
     "math-expression-evaluator": {
       "version": "1.2.17",
       "resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz",
@@ -7006,6 +7248,14 @@
       "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz",
       "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ=="
     },
+    "min-document": {
+      "version": "2.19.0",
+      "resolved": "https://registry.npmjs.org/min-document/-/min-document-2.19.0.tgz",
+      "integrity": "sha1-e9KC4/WELtKVu3SM3Z8f+iyCRoU=",
+      "requires": {
+        "dom-walk": "0.1.1"
+      }
+    },
     "minimalistic-assert": {
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.0.tgz",
@@ -7225,6 +7475,11 @@
       "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz",
       "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI="
     },
+    "normalize-scroll-left": {
+      "version": "0.1.2",
+      "resolved": "https://registry.npmjs.org/normalize-scroll-left/-/normalize-scroll-left-0.1.2.tgz",
+      "integrity": "sha512-F9YMRls0zCF6BFIE2YnXDRpHPpfd91nOIaNdDgrx5YMoPLo8Wqj+6jNXHQsYBavJeXP4ww8HCt0xQAKc5qk2Fg=="
+    },
     "normalize-url": {
       "version": "1.9.1",
       "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-1.9.1.tgz",
@@ -7700,6 +7955,11 @@
       "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-7.0.0.tgz",
       "integrity": "sha512-ARhBOdzS3e41FbkW/XWrTEtukqqLoK5+Z/4UeDaLuSW+39JPeFgs4gCGqsrJHVZX0fUrx//4OF0K1CUGwlIFow=="
     },
+    "popper.js": {
+      "version": "1.14.1",
+      "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.14.1.tgz",
+      "integrity": "sha1-uIFeXNpvYvwgQuR2GGSfdYZuZ1M="
+    },
     "portfinder": {
       "version": "1.0.13",
       "resolved": "https://registry.npmjs.org/portfinder/-/portfinder-1.0.13.tgz",
@@ -9021,6 +9281,14 @@
         "performance-now": "2.1.0"
       }
     },
+    "rafl": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/rafl/-/rafl-1.2.2.tgz",
+      "integrity": "sha1-/pMPdYIRAg1H44gV9Rlqi+QVB0A=",
+      "requires": {
+        "global": "4.3.2"
+      }
+    },
     "randomatic": {
       "version": "1.1.7",
       "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-1.1.7.tgz",
@@ -9143,6 +9411,17 @@
       "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-4.0.0.tgz",
       "integrity": "sha512-FlsPxavEyMuR6TjVbSSywovXSEyOg6ZDj5+Z8nbsRl9EkOzAhEIcS+GLoQDC5fz/t9suhUXWmUrOBrgeUvrMxw=="
     },
+    "react-event-listener": {
+      "version": "0.5.3",
+      "resolved": "https://registry.npmjs.org/react-event-listener/-/react-event-listener-0.5.3.tgz",
+      "integrity": "sha512-fTGYvhe7eTsqq0m664Km0rxKQcqLIGZWZINmy1LU0fu312tay8Mt3Twq2P5Xj1dfDVvvzT1Ql3/FDkiMPJ1MOg==",
+      "requires": {
+        "babel-runtime": "6.26.0",
+        "fbjs": "0.8.16",
+        "prop-types": "15.6.1",
+        "warning": "3.0.0"
+      }
+    },
     "react-infinite-scroller": {
       "version": "1.1.3",
       "resolved": "https://registry.npmjs.org/react-infinite-scroller/-/react-infinite-scroller-1.1.3.tgz",
@@ -9159,6 +9438,59 @@
         "prop-types": "15.6.1"
       }
     },
+    "react-jss": {
+      "version": "8.4.0",
+      "resolved": "https://registry.npmjs.org/react-jss/-/react-jss-8.4.0.tgz",
+      "integrity": "sha512-yIi4udcTIIh5u4KJ47wsL3UZYMuSrp5xR1YBvPeRNshpCdRoJxt5BWmCu1RA3LIa+//dnRsAtAQmMAYeg1W9oQ==",
+      "requires": {
+        "hoist-non-react-statics": "2.5.0",
+        "jss": "9.8.1",
+        "jss-preset-default": "4.3.0",
+        "prop-types": "15.6.1",
+        "theming": "1.3.0"
+      }
+    },
+    "react-lifecycles-compat": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-1.1.1.tgz",
+      "integrity": "sha512-XGN6uehZxZx/EZ6RXJ5L/9tiCYvL8kk9xNnXQCbEdM1MX9fmp6dLwvBuCIIZArxW8fJ7gxFJDmYpCYVizBLmyw=="
+    },
+    "react-popper": {
+      "version": "0.8.3",
+      "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-0.8.3.tgz",
+      "integrity": "sha1-D3MzMTfJ+wr27EB00tBYWgoEYeE=",
+      "requires": {
+        "popper.js": "1.14.1",
+        "prop-types": "15.6.1"
+      }
+    },
+    "react-router": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/react-router/-/react-router-4.2.0.tgz",
+      "integrity": "sha512-DY6pjwRhdARE4TDw7XjxjZsbx9lKmIcyZoZ+SDO7SBJ1KUeWNxT22Kara2AC7u6/c2SYEHlEDLnzBCcNhLE8Vg==",
+      "requires": {
+        "history": "4.7.2",
+        "hoist-non-react-statics": "2.5.0",
+        "invariant": "2.2.4",
+        "loose-envify": "1.3.1",
+        "path-to-regexp": "1.7.0",
+        "prop-types": "15.6.1",
+        "warning": "3.0.0"
+      }
+    },
+    "react-router-dom": {
+      "version": "4.2.2",
+      "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-4.2.2.tgz",
+      "integrity": "sha512-cHMFC1ZoLDfEaMFoKTjN7fry/oczMgRt5BKfMAkTu5zEuJvUiPp1J8d0eXSVTnBh6pxlbdqDhozunOOLtmKfPA==",
+      "requires": {
+        "history": "4.7.2",
+        "invariant": "2.2.4",
+        "loose-envify": "1.3.1",
+        "prop-types": "15.6.1",
+        "react-router": "4.2.0",
+        "warning": "3.0.0"
+      }
+    },
     "react-scripts": {
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-1.1.1.tgz",
@@ -9214,6 +9546,17 @@
         }
       }
     },
+    "react-scrollbar-size": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/react-scrollbar-size/-/react-scrollbar-size-2.1.0.tgz",
+      "integrity": "sha512-9dDUJvk7S48r0TRKjlKJ9e/LkLLYgc9LdQR6W21I8ZqtSrEsedPOoMji4nU3DHy7fx2l8YMScJS/N7qiloYzXQ==",
+      "requires": {
+        "babel-runtime": "6.26.0",
+        "prop-types": "15.6.1",
+        "react-event-listener": "0.5.3",
+        "stifle": "1.0.4"
+      }
+    },
     "react-select": {
       "version": "git://github.com/enikesha/react-select.git#c43000630899b664da4e77d43b28310c5836835a",
       "requires": {
@@ -9231,6 +9574,18 @@
         "soundmanager2": "2.97.20170602"
       }
     },
+    "react-transition-group": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.3.0.tgz",
+      "integrity": "sha512-OU3/swEL8y233u5ajTn3FIcQQ/b3XWjLXB6e2LnM1OK5JATtsyfJvPTZ8c/dawHNqjUltcdHRSpgMtPe7v07pw==",
+      "requires": {
+        "chain-function": "1.0.0",
+        "dom-helpers": "3.3.1",
+        "loose-envify": "1.3.1",
+        "prop-types": "15.6.1",
+        "warning": "3.0.0"
+      }
+    },
     "react-virtualized": {
       "version": "9.18.5",
       "resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.18.5.tgz",
@@ -9317,6 +9672,17 @@
         "set-immediate-shim": "1.0.1"
       }
     },
+    "recompose": {
+      "version": "0.26.0",
+      "resolved": "https://registry.npmjs.org/recompose/-/recompose-0.26.0.tgz",
+      "integrity": "sha512-KwOu6ztO0mN5vy3+zDcc45lgnaUoaQse/a5yLVqtzTK13czSWnFGmXbQVmnoMgDkI5POd1EwIKSbjU1V7xdZog==",
+      "requires": {
+        "change-emitter": "0.1.6",
+        "fbjs": "0.8.16",
+        "hoist-non-react-statics": "2.5.0",
+        "symbol-observable": "1.2.0"
+      }
+    },
     "recursive-readdir": {
       "version": "2.2.1",
       "resolved": "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.1.tgz",
@@ -9602,6 +9968,11 @@
       "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-1.0.1.tgz",
       "integrity": "sha1-Jsv+k10a7uq7Kbw/5a6wHpPUQiY="
     },
+    "resolve-pathname": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/resolve-pathname/-/resolve-pathname-2.2.0.tgz",
+      "integrity": "sha512-bAFz9ld18RzJfddgrO2e/0S2O81710++chRMUxHjXOYKF6jTAMrUNZrEZ1PvV0zlhfjidm08iRPdTLPno1FuRg=="
+    },
     "resolve-url": {
       "version": "0.2.1",
       "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz",
@@ -9730,6 +10101,14 @@
         "ajv": "5.5.2"
       }
     },
+    "scroll": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/scroll/-/scroll-2.0.3.tgz",
+      "integrity": "sha512-3ncZzf8gUW739h3LeS68nSssO60O+GGjT3SxzgofQmT8PIoyHzebql9HHPJopZX8iT6TKOdwaWFMqL6LzUN3DQ==",
+      "requires": {
+        "rafl": "1.2.2"
+      }
+    },
     "select-hose": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/select-hose/-/select-hose-2.0.0.tgz",
@@ -10295,6 +10674,11 @@
       "resolved": "https://registry.npmjs.org/statuses/-/statuses-1.4.0.tgz",
       "integrity": "sha512-zhSCtt8v2NDrRlPQpCNtw/heZLtfUDqxBM1udqikb/Hbk52LK4nQSwr10u77iopCW5LsyHpuXS0GnEc48mLeew=="
     },
+    "stifle": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/stifle/-/stifle-1.0.4.tgz",
+      "integrity": "sha1-izvN9SQZsKnHnjWtrc5QEjwdjpk="
+    },
     "stream-browserify": {
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.1.tgz",
@@ -10467,6 +10851,11 @@
         "serviceworker-cache-polyfill": "4.0.0"
       }
     },
+    "symbol-observable": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz",
+      "integrity": "sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ=="
+    },
     "symbol-tree": {
       "version": "3.2.2",
       "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.2.tgz",
@@ -10537,6 +10926,17 @@
       "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz",
       "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ="
     },
+    "theming": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/theming/-/theming-1.3.0.tgz",
+      "integrity": "sha512-ya5Ef7XDGbTPBv5ENTwrwkPUexrlPeiAg/EI9kdlUAZhNlRbCdhMKRgjNX1IcmsmiPcqDQZE6BpSaH+cr31FKw==",
+      "requires": {
+        "brcast": "3.0.1",
+        "is-function": "1.0.1",
+        "is-plain-object": "2.0.4",
+        "prop-types": "15.6.1"
+      }
+    },
     "throat": {
       "version": "3.2.0",
       "resolved": "https://registry.npmjs.org/throat/-/throat-3.2.0.tgz",
@@ -10700,6 +11100,11 @@
       "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
       "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c="
     },
+    "typeface-roboto": {
+      "version": "0.0.54",
+      "resolved": "https://registry.npmjs.org/typeface-roboto/-/typeface-roboto-0.0.54.tgz",
+      "integrity": "sha512-sOFA1FXgP0gOgBYlS6irwq6hHYA370KE3dPlgYEJHL3PJd5X8gQE0RmL79ONif6fL5JZuGDj+rtOrFeOqz5IZQ=="
+    },
     "ua-parser-js": {
       "version": "0.7.17",
       "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.17.tgz",
@@ -11017,6 +11422,11 @@
         "spdx-expression-parse": "3.0.0"
       }
     },
+    "value-equal": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-0.4.0.tgz",
+      "integrity": "sha512-x+cYdNnaA3CxvMaTX0INdTCN8m8aF2uY9BvEqmxuYp8bL09cs/kWVQPVGcA35fMktdOsP69IgU7wFj/61dJHEw=="
+    },
     "vary": {
       "version": "1.1.2",
       "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -11053,6 +11463,14 @@
         "makeerror": "1.0.11"
       }
     },
+    "warning": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/warning/-/warning-3.0.0.tgz",
+      "integrity": "sha1-MuU3fLVy3kqwR1O9+IIcAe1gW3w=",
+      "requires": {
+        "loose-envify": "1.3.1"
+      }
+    },
     "watch": {
       "version": "0.10.0",
       "resolved": "https://registry.npmjs.org/watch/-/watch-0.10.0.tgz",

+ 5 - 1
front/package.json

@@ -3,14 +3,18 @@
   "version": "0.1.0",
   "private": true,
   "dependencies": {
+    "material-ui": "^1.0.0-beta.40",
+    "material-ui-icons": "^1.0.0-beta.36",
     "react": "^16.2.0",
     "react-dom": "^16.2.0",
     "react-infinite-scroller": "^1.1.3",
+    "react-router-dom": "^4.2.2",
     "react-scripts": "1.1.1",
     "react-select": "git://github.com/enikesha/react-select.git#feature/async-pagination",
     "react-sound": "^1.1.0",
     "react-virtualized": "^9.18.5",
-    "react-virtualized-select": "^3.1.3"
+    "react-virtualized-select": "^3.1.3",
+    "typeface-roboto": "0.0.54"
   },
   "proxy": "http://localhost:5000/",
   "scripts": {

+ 1 - 1
front/public/index.html

@@ -2,7 +2,7 @@
 <html lang="en">
   <head>
     <meta charset="utf-8">
-    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
+    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1.0">
     <meta name="theme-color" content="#000000">
     <!--
       manifest.json provides metadata used when your web app is added to the

+ 1 - 0
front/src/AlbumList.js

@@ -58,6 +58,7 @@ export default function AlbumList({loadMore, hasMore, albums, title, error, ...p
             hasMore={hasMore}
             loader={<div className="loader" key={0}>Loading ...</div>}
             initialLoad={false}
+            useWindow={false}
           >
             {albums.map((album, idx) => (
               <Album key={idx} album={album} {..._albumProps(albums, idx)} {...props} />

+ 28 - 43
front/src/App.css

@@ -1,28 +1,44 @@
-.App-logo {
-  height: 64px;
-  padding: 8px;
-  float: left;
+.App {
+  display: flex;
+  height: 100%;
+  flex-direction: column;
+  justify-content: space-between;
 }
 
 .App-header {
-  background-color: #222;
-  height: 80px;
+  background-color: #1b5e20;
+  height: 56px;
   color: white;
+  display: flex;
+  flex-direction: row;
+  flex: initial;
+}
+
+.App-logo {
+  height: 24px;
+  padding: 16px;
 }
 
 .App-title {
-  font-size: 1.5em;
+  font-size: 20px;
   margin: 0;
-  line-height: 80px;
+  line-height: 56px;
+}
+
+.Selector {
+  flex: 1;
+  overflow: auto;
 }
 
-.App-intro {
-  font-size: large;
+.Playlist {
+  flex: 1;
+  overflow: auto;
 }
 
 .Player {
-  text-align: left;
-  padding: 0 20px;
+  flex:initial;
+  height: 56px;
+  background: #4c8c4a;
 }
 
 .section {
@@ -40,37 +56,6 @@
   border-radius: 4px;
 }
 
-.Controls {
-  margin: 16px 0;
-}
-.Controls-progress {
-  width: 540px;
-  border: 1px black solid;
-  height: 16px;
-  position: relative;
-  margin-bottom: 8px;
-}
-.Controls-progress span {
-  font-size: 14px;
-}
-.Controls-bar {
-  background: cadetblue;
-  height: 16px;
-}
-.Controls-elapsed {
-  position: absolute;
-  left: 2px;
-  top: 0;
-}
-.Controls-duration {
-  position: absolute;
-  right: 2px;
-  top: 0;
-}
-.Controls button {
-  margin-right: 8px;
-}
-
 .Playlist-active {
   background: darkseagreen;
 }

+ 129 - 8
front/src/App.js

@@ -1,21 +1,142 @@
-import "react-select/dist/react-select.css";
-import "react-virtualized/styles.css";
-import "react-virtualized-select/styles.css";
-
-import React, { Component } from 'react';
-import Player from './Player.js';
+import React from 'react';
+import { Switch, Route } from 'react-router-dom'
 import logo from './chad-logo-256.png';
 import './App.css';
+import Selector from './Selector.js';
+import Player from './Player.js';
+import Playlist from './Playlist.js';
+import Sound from 'react-sound';
+import MediaSession from './MediaSession.js';
+
+const fetchOpts = { credentials: 'same-origin' }
 
-class App extends Component {
+class App extends React.Component {
+  constructor(props) {
+    super(props);
+    this.state = {
+      sound: {url: '', playStatus: Sound.status.STOPPED},
+      activeTrack: -1,
+    };
+  }
+  activateTrack(activeTrack) {
+    if (!Array.isArray(this.state.tracks)) {
+      return;
+    }
+    if (activeTrack < 0 || activeTrack >= this.state.tracks.length) {
+      activeTrack = -1;
+    }
+    this.setState({activeTrack})
+  }
+  componentDidUpdate(prevProps, prevState) {
+    const {activeTrack} = this.state;
+    if (activeTrack !== prevState.activeTrack) {
+      if (activeTrack !== -1 && Array.isArray(this.state.tracks)) {
+        const track = this.state.tracks[activeTrack];
+        if (!track) {
+          console.log('Bad activeTrack', this.state.tracks, activeTrack);
+        } else {
+          this.setState({
+            sound: {
+              url: track.url,
+              position: 0,
+              playStatus: Sound.status.PLAYING
+            },
+            metadata: {
+              title: track.title,
+              artist: track.artist,
+              album: track.album.album,
+              cover: track.album.cover
+            }
+          })
+        }
+      } else {
+        this.setState({
+          sound: {url: '', position: 0, playStatus: Sound.status.STOPPED},
+          metadata: null
+        })
+      }
+    }
+  }
+  handleActivateTrack = (activeTrack) => this.activateTrack(activeTrack)
+  handleControlPrev = () => this.activateTrack(this.state.activeTrack - 1)
+  handleControlNext = () => this.activateTrack(this.state.activeTrack + 1)
+  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});
+  }
+  handleSoundError = (errorCode, description) => {
+    console.log('sound error', errorCode, description)
+    // try next track, TODO: better error handling
+    this.handleControlNext();
+  }
+  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();
+  }
+  fetchAlbumTracks(album) {
+    return fetch(`/album/${album.id}/tracks`, fetchOpts)
+      .then(res => (res.ok ? res.json() : Promise.reject({message:res.statusText})))
+      .then(tracks => tracks.map(t => Object.assign(t, {album})))
+  }
+  onPlayAlbum = (album) => {
+    this.fetchAlbumTracks(album)
+      .then(tracks => this.setState({tracks, activeTrack: 0}))
+      .catch(error => this.setState({tracks: error.message}));
+    this.setState({tracks: "Loading"})
+  }
   render() {
+    const playlist = (<Playlist
+                      open={this.state.playlistOpen}
+                      tracks={this.state.tracks}
+                      activeTrack={this.state.activeTrack}
+                      onActivateTrack={this.handleActivateTrack} />)
+    const selector = (<Selector onPlayAlbum={this.onPlayAlbum} />)
     return (
       <div className="App">
         <header className="App-header">
           <img src={logo} className="App-logo" alt="logo" />
           <h1 className="App-title">Chad Music</h1>
         </header>
-        <Player />
+        <Switch>
+          <Route exact path="/" render={() => selector} />
+          <Route path="/playlist" render={() => playlist} />
+        </Switch>
+        <Player
+          {...this.state.sound}
+          {...this.state.metadata}
+          onPlaylistToggle={() => this.setState({playlistOpen: !this.state.playlistOpen})}
+          onPrev={this.handleControlPrev}
+          onPlayPause={this.handleControlPlayPause}
+          onStop={this.handleControlStop}
+          onNext={this.handleControlNext} />
+        <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>
     );
   }

+ 0 - 29
front/src/Controls.js

@@ -1,29 +0,0 @@
-import React from 'react';
-import Sound from 'react-sound';
-import {formatDuration} from './utils.js';
-
-export default 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) : '');
-  if (props.visible === false) {
-    return null;
-  }
-  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>
-  );
-}

+ 4 - 1
front/src/MediaSession.js

@@ -7,7 +7,10 @@ export default class MediaSession extends React.Component {
     }
     const {title, artist, album, cover} = this.props;
     const artwork = cover ? [{src: cover}] : undefined;
-    navigator.mediaSession.metadata = new window.MediaMetadata({title, artist, album, artwork});
+    const hasAny = !!(title || artist || album || cover);
+    navigator.mediaSession.metadata = (hasAny
+                                       ? new window.MediaMetadata({title, artist, album, artwork})
+                                       : null);
   }
   updateEvents() {
     if (!('mediaSession' in navigator)) {

+ 51 - 0
front/src/Player.css

@@ -0,0 +1,51 @@
+.Player {
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+}
+.Player--cover {
+  background: #F06292;
+  height: 56px;
+  width: 56px;
+  flex: initial;
+}
+.Player--container {
+  font-size: 16px;
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+}
+.Player--progress {
+  height: 4px;
+  flex: initial;
+  background: #003300;
+}
+.Player--progress--bar {
+  background-color: #F06292;
+  height: 4px;
+}
+.Player--controls {
+  padding: 4px 0 0;
+  display: flex;
+  flex-direction: row;
+  justify-content: space-between;
+}
+.Player--controls button {
+  margin-right: 8px;
+  padding: 0;
+  border: 0;
+  background: inherit;
+}
+.Player--controls button:last-of-type {
+  margin-right: 0;
+}
+.Player--controls span {
+  padding: 0 8px;
+  line-height: 21px;
+}
+.Player--title {
+  font-size: 14px;
+  overflow: hidden;
+  padding: 4px 8px;
+}

+ 38 - 278
front/src/Player.js

@@ -1,283 +1,43 @@
-import React, {Component} from 'react';
-import Select from "react-virtualized-select";
+import React from 'react';
+import { Link } from 'react-router-dom'
 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 = {
-      albumList: {key:'', albums: [], error: false, hasMore: false},
-      filters: {},
-      filter: '',
-      sound: {url: '', playStatus: Sound.status.STOPPED},
-      tracks: null,
-      activeTrack: -1,
-      activeAlbum: null,
-      metadata: {artist: '', album: '', title: '', cover: ''}
-    };
-    this.filterTimeout = null;
-  }
-  genAlbumListKey() {
-    const {filter, filters} = this.state
-    return `${filter}-${this.selects.map(({type}) => filters[type] ? filters[type].item : '').join('-')}`
-  }
-  loadAlbums = (page) => {
-    const {filter, filters} = this.state
-    const limit  = 15;
-    const offset = page * limit;
-    const params = {filter, offset, limit:(limit+1)};
-    let key = (page === 0 ? this.genAlbumListKey() : this.state.albumList.key)
-    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(data => {
-        const hasMore = (data.length > limit)
-        const albums = page === 0 ? data.slice(0, limit) : this.state.albumList.albums.concat(data.slice(0, limit))
-        const albumList = {key, albums, hasMore}
-        this.setState({albumList})
-        return albums;
-      })
-      .catch(error => {
-        console.log(error)
-        const albumList = {key, error}
-        this.setState({albumList})
-      })
-  }
-  componentDidMount() {
-    this.loadAlbums(0)
-  }
-  componentDidUpdate(prevProps, prevState) {
-    const {filters} = this.state;
-    if (filters !== prevState.filters) {
-      this.loadAlbums(0);
-    }
-    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(0);
-    }, 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]}
-              />
+import PlayCircleFilledIcon from 'material-ui-icons/PlayCircleFilled';
+import PauseCircleFilledIcon from 'material-ui-icons/PauseCircleFilled';
+import SkipNextIcon from 'material-ui-icons/SkipNext';
+import SkipPreviousIcon from 'material-ui-icons/SkipPrevious';
+
+import {formatDuration} from './utils.js';
+import './Player.css';
+import defaultCover from './logo.svg';
+
+export default function Player(props) {
+  const playPause = (props.playStatus === Sound.status.PLAYING
+                     ? <PauseCircleFilledIcon />
+                     : <PlayCircleFilledIcon />);
+  const elapsed  = (props.position ? formatDuration(props.position/1000) : '00:00');
+  const duration = (props.duration ? formatDuration(props.duration/1000) : '05:00');
+  const position = ((props.duration ? (props.position / props.duration) : 0.3) * 100)+'%';
+  const {title, artist, cover} = props
+  const titleText = (artist ? `${artist} - ${title}` : 'Some very long artist - And very long title');
+
+  return (
+      <div className="Player" onClick={props.onPlaylistToggle}>
+        <img className="Player--cover" src={cover || defaultCover} alt={titleText} />
+        <div className="Player--container">
+          <div className="Player--progress">
+            <div className="Player--progress--bar" style={{width: position}}></div>
+          </div>
+          <div className="Player--controls">
+            <span>{elapsed}</span>
+            <div className="Player-buttons">
+              <button onClick={props.onPrev}><SkipPreviousIcon /></button>
+              <button onClick={props.onPlayPause} style={{color:"#F06292"}}>{playPause}</button>
+              <button onClick={props.onNext}><SkipNextIcon /></button>
             </div>
-          ))}
-          <div className="section">
-            <h3>Search</h3>
-            <input className="Filter"
-              type="text"
-              value={this.state.filter}
-              placeholder="Search albums"
-              onChange={this.handleFilterChange} />
+            <span>{duration}</span>
           </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"
-          {...this.state.albumList}
-          loadMore={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 className="Player--title"><Link to="/playlist">{titleText}</Link></div>
+        </div>
       </div>
-    );
-  }
+  );
 }

+ 16 - 10
front/src/Playlist.js

@@ -1,27 +1,33 @@
 import React from 'react';
+import { Link } from 'react-router-dom'
 import {formatDuration} from './utils.js';
 
 export default function Playlist(props) {
   const {tracks} = props
+  let content;
   if (!tracks) {
-    return null;
-  }
-  if (!Array.isArray(tracks)) {
-    return <div className="Playlist-error">{tracks}</div>;
-  }
-  return (
-    <table className="Playlist"><tbody>
+    content = <div className="Playlist--empty">Playlist empty</div>;
+  } else if (!Array.isArray(tracks)) {
+    content = <div className="Playlist-error">{tracks}.</div>;
+  } else {
+    content = <table><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.album.artist}</td>
         <td>{track.year}</td>
-        <td>{track.album}</td>
+        <td>{track.album.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>
+    </tbody></table>;
+  }
+  return (
+    <div className="Playlist">
+      <Link to="/">Back</Link>
+      {content}
+    </div>
   )
 }

+ 163 - 0
front/src/Selector.js

@@ -0,0 +1,163 @@
+import React, {Component} from 'react';
+import Select from "react-virtualized-select";
+import AlbumList from './AlbumList.js';
+import {getQueryString} from './utils.js';
+
+const fetchOpts = { credentials: 'same-origin' }
+
+export default class Selector 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 = {
+      albumList: {key:'', albums: [], error: false, hasMore: false},
+      filters: {},
+      filter: '',
+    };
+    this.filterTimeout = null;
+  }
+  genAlbumListKey() {
+    const {filter, filters} = this.state
+    return `${filter}-${this.selects.map(({type}) => filters[type] ? filters[type].item : '').join('-')}`
+  }
+  loadAlbums = (page) => {
+    const {filter, filters} = this.state
+    const limit  = 15;
+    const offset = page * limit;
+    const params = {filter, offset, limit:(limit+1)};
+    let key = (page === 0 ? this.genAlbumListKey() : this.state.albumList.key)
+    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(data => {
+        const hasMore = (data.length > limit)
+        const albums = page === 0 ? data.slice(0, limit) : this.state.albumList.albums.concat(data.slice(0, limit))
+        const albumList = {key, albums, hasMore}
+        this.setState({albumList})
+        return albums;
+      })
+      .catch(error => {
+        console.log(error)
+        const albumList = {key, error}
+        this.setState({albumList})
+      })
+  }
+  componentDidMount() {
+    this.loadAlbums(0)
+  }
+  componentDidUpdate(prevProps, prevState) {
+    const {filters} = this.state;
+    if (filters !== prevState.filters) {
+      this.loadAlbums(0);
+    }
+  }
+  handleFilterChange = (e) => {
+    if (this.filterTimeout) {
+      clearInterval(this.filterTimeout);
+    }
+    const filter = e.target.value;
+    this.filterTimeout = setTimeout(() => {
+      this.loadAlbums(0);
+    }, 500);
+    this.setState({filter})
+  }
+  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="Selector">
+        <div className="SelectorFilters">
+          {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'}} />
+        <AlbumList
+          title="Albums"
+          {...this.state.albumList}
+          loadMore={this.loadAlbums}
+          onPlayAlbum={this.props.onPlayAlbum} />
+      </div>
+    );
+  }
+}

+ 6 - 1
front/src/index.css

@@ -1,5 +1,10 @@
 body {
+  font-family: Roboto, sans-serif;
   margin: 0;
   padding: 0;
-  font-family: sans-serif;
+  box-sizing: border-box;
+}
+
+html, body, #root {
+  height: 100%;
 }

+ 3 - 1
front/src/index.js

@@ -1,8 +1,10 @@
 import React from 'react';
 import ReactDOM from 'react-dom';
+import { BrowserRouter } from 'react-router-dom'
+import "typeface-roboto";
 import './index.css';
 import App from './App';
 import registerServiceWorker from './registerServiceWorker';
 
-ReactDOM.render(<App />, document.getElementById('root'));
+ReactDOM.render(<BrowserRouter><App /></BrowserRouter>, document.getElementById('root'));
 registerServiceWorker();