├── summertunes
├── __init__.py
├── static
│ ├── favicon.ico
│ ├── server_config.js
│ ├── static
│ │ └── css
│ │ │ ├── main.3bd60646.css.map
│ │ │ └── main.3bd60646.css
│ ├── asset-manifest.json
│ └── index.html
├── config_defaults.py
├── cli
│ ├── run_mpv.py
│ ├── run_serve.py
│ └── __init__.py
├── routes.py
└── mpv2websocket.py
├── client
├── build
├── src
│ ├── css
│ │ ├── .gitignore
│ │ ├── index.scss
│ │ ├── _common.scss
│ │ ├── BottomBar.scss
│ │ ├── base.scss
│ │ ├── modal.scss
│ │ ├── NowPlaying.scss
│ │ ├── TrackList.scss
│ │ ├── Table.scss
│ │ ├── Toolbar.scss
│ │ ├── App.scss
│ │ └── normalize.scss
│ ├── ui
│ │ ├── App.test.js
│ │ ├── TrackInfo.js
│ │ ├── Toolbar.js
│ │ ├── PlaybackControls.js
│ │ ├── BottomBar.js
│ │ ├── NowPlaying.js
│ │ ├── AlbumList.js
│ │ ├── ArtistList.js
│ │ ├── Playlist.js
│ │ ├── App.js
│ │ └── TrackList.js
│ ├── util
│ │ ├── makeURLQuery.js
│ │ ├── localStorageJSON.js
│ │ ├── parseURLQuery.js
│ │ ├── scrollIntoView.js
│ │ ├── secondsToString.js
│ │ ├── KComponent.js
│ │ ├── react-mousetrap.js
│ │ ├── svgShapes.js
│ │ └── webAudioWrapper.js
│ ├── index.js
│ ├── model
│ │ ├── createBus.js
│ │ ├── trackQueryString.js
│ │ ├── keyboardModel.js
│ │ ├── uiModel.js
│ │ ├── webPlayer.js
│ │ ├── playerModel.js
│ │ ├── browsingModel.js
│ │ └── mpvPlayer.js
│ ├── config.js
│ └── uilib
│ │ ├── List.js
│ │ └── Table.js
├── .gitignore
├── public
│ ├── favicon.ico
│ └── index.html
├── jsconfig.json
└── package.json
├── .eslintrc.json
├── requirements.txt
├── .gitignore
├── summertunes.sublime-project
├── beetsplug
└── summertunes.py
├── setup.py
├── LICENSE
├── Readme.rst
└── .pylintrc
/summertunes/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/client/build:
--------------------------------------------------------------------------------
1 | ../summertunes/static
--------------------------------------------------------------------------------
/client/src/css/.gitignore:
--------------------------------------------------------------------------------
1 | *.css
2 |
--------------------------------------------------------------------------------
/client/.gitignore:
--------------------------------------------------------------------------------
1 | public/server_config.js
2 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "react-app"
3 | }
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | eventlet>=0.20.0
2 | Flask>=0.11.1
3 | Flask-SocketIO>=2.8.2
4 | beets>=1.4.4
5 |
--------------------------------------------------------------------------------
/client/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irskep/summertunes/HEAD/client/public/favicon.ico
--------------------------------------------------------------------------------
/client/src/css/index.scss:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: sans-serif;
5 | }
6 |
--------------------------------------------------------------------------------
/summertunes/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/irskep/summertunes/HEAD/summertunes/static/favicon.ico
--------------------------------------------------------------------------------
/summertunes/static/server_config.js:
--------------------------------------------------------------------------------
1 | {"BEETSWEB_PORT": 8337, "MPV_PORT": 3001, "LAST_FM_API_KEY": "", "player_services": ["web", "mpv"]}
--------------------------------------------------------------------------------
/summertunes/static/static/css/main.3bd60646.css.map:
--------------------------------------------------------------------------------
1 | {"version":3,"sources":[],"names":[],"mappings":"","file":"static/css/main.3bd60646.css","sourceRoot":""}
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | *.sublime-workspace
3 | tags
4 | __pycache__/
5 | .vscode/
6 | summertunes.conf
7 | client/build/server_config.js
8 | *.egg-info/
9 |
--------------------------------------------------------------------------------
/client/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES6"
4 | },
5 | "exclude": [
6 | "node_modules",
7 | "public"
8 | ]
9 | }
--------------------------------------------------------------------------------
/summertunes/config_defaults.py:
--------------------------------------------------------------------------------
1 | CONFIG_DEFAULTS = {
2 | 'mpv_websocket_port': 3001,
3 | 'mpv_socket_path': '/tmp/mpv_socket',
4 | 'mpv_enabled': True,
5 | 'dev_server_port': 3000,
6 | 'last_fm_api_key': "",
7 | }
8 |
--------------------------------------------------------------------------------
/summertunes/static/asset-manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "main.css": "static/css/main.3bd60646.css",
3 | "main.css.map": "static/css/main.3bd60646.css.map",
4 | "main.js": "static/js/main.748b3be7.js",
5 | "main.js.map": "static/js/main.748b3be7.js.map"
6 | }
--------------------------------------------------------------------------------
/client/src/ui/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 |
5 | it('renders without crashing', () => {
6 | const div = document.createElement('div');
7 | ReactDOM.render(, div);
8 | });
9 |
--------------------------------------------------------------------------------
/summertunes.sublime-project:
--------------------------------------------------------------------------------
1 | {
2 | "folders":
3 | [
4 | {
5 | "path": ".",
6 | "folder_exclude_patterns": ["node_modules", "build", "__pycache__", "*.egg-info"],
7 | "file_exclude_patterns": ["*.css", "tags"],
8 | }
9 | ]
10 | }
11 |
--------------------------------------------------------------------------------
/client/src/util/makeURLQuery.js:
--------------------------------------------------------------------------------
1 | export default function makeURLQuery(items) {
2 | return (
3 | '?' +
4 | Object.keys(items)
5 | .filter((k) => items[k] !== null)
6 | .map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(items[k])}`)
7 | .join('&')
8 | );
9 | }
--------------------------------------------------------------------------------
/client/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './ui/App';
4 | import './css/index.css';
5 | import './css/normalize.css';
6 |
7 | ReactDOM.render(
8 | ,
9 | document.getElementById('root') // eslint-disable-line no-undef
10 | );
11 |
--------------------------------------------------------------------------------
/client/src/util/localStorageJSON.js:
--------------------------------------------------------------------------------
1 | export default function localStorageJSON(key, defaultValue=null) {
2 | if (localStorage[key]) {
3 | try {
4 | return JSON.parse(localStorage[key]);
5 | } catch (e) {
6 | return defaultValue;
7 | }
8 | } else {
9 | return defaultValue;
10 | }
11 | }
--------------------------------------------------------------------------------
/client/src/model/createBus.js:
--------------------------------------------------------------------------------
1 | import K from "kefir";
2 |
3 |
4 | export default function createBus(label) {
5 | let outerEmitter;
6 | const stream = K.stream((emitter) => {
7 | outerEmitter = emitter.emit;
8 | return () => {
9 | }
10 | });
11 | const push = (...args) => {
12 | if (!outerEmitter) return;
13 | return outerEmitter(...args);
14 | };
15 |
16 | return [push, stream];
17 | }
--------------------------------------------------------------------------------
/client/src/util/parseURLQuery.js:
--------------------------------------------------------------------------------
1 | export default function parseURLQuery(query) {
2 | const result = {};
3 | for (const segment of query.split('&')) {
4 | const equalIndex = segment.indexOf("=");
5 | if (equalIndex > -1) {
6 | const key = segment.slice(0, equalIndex);
7 | const value = segment.slice(equalIndex + 1);
8 | result[decodeURIComponent(key)] = decodeURIComponent(value);
9 | }
10 | }
11 | return result;
12 | }
--------------------------------------------------------------------------------
/client/src/model/trackQueryString.js:
--------------------------------------------------------------------------------
1 | /// pass {artist, album, id}
2 | export default function queryString(obj2) {
3 | const obj = {
4 | albumartist: obj2.artist,
5 | album: obj2.album,
6 | id: obj2.id,
7 | };
8 | const components = [];
9 | for (const k of Object.keys(obj)) {
10 | if (obj[k] !== null && typeof(obj[k]) !== 'undefined') {
11 | components.push(`${k}=${encodeURIComponent(obj[k])}`);
12 | }
13 | }
14 | return components.join('&');
15 | };
--------------------------------------------------------------------------------
/beetsplug/summertunes.py:
--------------------------------------------------------------------------------
1 | from beets.plugins import BeetsPlugin
2 | from beetsplug.web import app as beetsweb_app
3 |
4 | from summertunes.config_defaults import CONFIG_DEFAULTS
5 | from summertunes.routes import summertunes_routes
6 |
7 | class SummertunesPlugin(BeetsPlugin):
8 | def __init__(self):
9 | super(SummertunesPlugin, self).__init__()
10 | self.config.add(CONFIG_DEFAULTS)
11 | beetsweb_app.register_blueprint(summertunes_routes, url_prefix="/summertunes")
12 |
--------------------------------------------------------------------------------
/summertunes/static/index.html:
--------------------------------------------------------------------------------
1 |
Summertunes
--------------------------------------------------------------------------------
/client/src/util/scrollIntoView.js:
--------------------------------------------------------------------------------
1 | export default function scrollIntoView(element, container=null){
2 |
3 | container = container || element.parentElement;
4 |
5 | const minY = container.scrollTop;
6 | const maxY = container.scrollTop + container.clientHeight;
7 |
8 | if (element.offsetTop + element.clientHeight < minY) {
9 | container.scrollTop = element.offsetTop;
10 | } else if (element.offsetTop > maxY) {
11 | container.scrollTop = element.offsetTop + container.clientHeight - element.clientHeight;
12 | }
13 | }
--------------------------------------------------------------------------------
/client/src/util/secondsToString.js:
--------------------------------------------------------------------------------
1 | function pad(num, size) {
2 | let s = num + "";
3 | while (s.length < size) s = "0" + s;
4 | return s;
5 | }
6 |
7 | const MINUTE = 60;
8 | const HOUR = MINUTE * 60;
9 |
10 | export default function secondsToString(seconds) {
11 | seconds = Math.round(seconds);
12 |
13 | const hours = Math.floor(seconds / HOUR);
14 | seconds -= hours * HOUR;
15 |
16 | const minutes = Math.floor(seconds / MINUTE);
17 | seconds -= minutes * MINUTE;
18 |
19 | if (hours) {
20 | return `${pad(hours)}:${pad(minutes, 2)}:${pad(seconds, 2)}`;
21 | } else {
22 | return `${pad(minutes, 2)}:${pad(seconds, 2)}`;
23 | }
24 | }
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup, find_packages
2 |
3 | setup(
4 | name='summertunes',
5 | version='0.1',
6 | packages=find_packages(),
7 | description='A web-based music player for beets and mpv',
8 | long_description='`Documentation `_',
9 | keywords='beets music mpv mp3',
10 | url="http://steveasleep.com/summertunes",
11 | author='Steve Johnson',
12 | license='MIT',
13 | install_requires=[
14 | 'Click>=6',
15 | 'beets>=1.4.4',
16 | 'Flask>=0.11',
17 | 'eventlet>=0.20',
18 | ],
19 | entry_points='''
20 | [console_scripts]
21 | summertunes=summertunes.cli:cli
22 | ''',
23 | )
24 |
--------------------------------------------------------------------------------
/client/src/css/_common.scss:
--------------------------------------------------------------------------------
1 | $colorAppleUIBlue: rgb(60, 104, 214);
2 |
3 | $colorBorder: #ddd;
4 | $colorBorderLight: #eee;
5 | $colorBackground1: #fff;
6 | $colorBackground2: #eee;
7 | $colorListSelectionBackground: $colorAppleUIBlue;
8 | $colorListSelectionText: #fff;
9 | $colorTableRow: #fff;
10 | $colorTableRowAlternate: #f8f4f4;
11 | $colorNowPlayingText: #444;
12 | $colorNowPlayingBar: $colorAppleUIBlue;
13 | $colorToolbarButtonText: #666;
14 | $colorToolbarButtonBackgroundActive: #ccc;
15 |
16 | $colorText: #000;
17 | $colorTextFaint: #ddd;
18 | $colorTextLink: $colorAppleUIBlue;
19 |
20 | $heightListItemNormal: 20px;
21 | $heightListItemMobile: 40px;
22 | $heightListFilterControlNormal: 20px;
23 | $heightListFilterControlMobile: 40px;
24 |
--------------------------------------------------------------------------------
/client/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "summertunes",
3 | "version": "0.1.0",
4 | "private": true,
5 | "homepage": "/summertunes",
6 | "devDependencies": {
7 | "create-react-app-sass": "^1.1.0",
8 | "react-scripts": "0.8.4"
9 | },
10 | "dependencies": {
11 | "kefir": "^3.6.1",
12 | "moment": "^2.17.1",
13 | "mousetrap": "^1.6.0",
14 | "react": "^15.4.1",
15 | "react-addons-shallow-compare": "^15.4.1",
16 | "react-contextmenu": "^2.0.0",
17 | "react-dom": "^15.4.1",
18 | "socket.io-client": "^1.7.2"
19 | },
20 | "scripts": {
21 | "start": "react-scripts-with-sass start",
22 | "build": "react-scripts-with-sass build -h",
23 | "test": "react-scripts test --env=jsdom",
24 | "eject": "react-scripts eject"
25 | }
26 | }
27 |
--------------------------------------------------------------------------------
/client/src/ui/TrackInfo.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import KComponent from "../util/KComponent";
3 | import Table from "../uilib/Table";
4 | import { kInfoModalTrack } from "../model/uiModel";
5 |
6 | export default class TrackInfo extends KComponent {
7 | observables() { return {
8 | track: kInfoModalTrack.log('imt'),
9 | }; }
10 |
11 | render() {
12 | if (!this.state.track) return ;
13 | return
14 |
({key, value: this.state.track[key]}))}
20 | />
21 | ;
22 | }
23 | };
--------------------------------------------------------------------------------
/client/src/css/BottomBar.scss:
--------------------------------------------------------------------------------
1 | @import "common";
2 |
3 | .st-bottom-bar {
4 | position: absolute;
5 | right: 0; bottom: 0; left: 0;
6 | height: 50px;
7 |
8 | padding: 4px;
9 | border-top: 1px solid $colorBorder;
10 | flex-grow: 0;
11 | flex-shrink: 0;
12 | background-color: $colorBackground2;
13 |
14 | width: 100%;
15 |
16 | display: flex;
17 | flex-direction: row;
18 | flex-wrap: nowrap;
19 | align-items: center;
20 | justify-content: flex-end;
21 |
22 | .st-bottom-bar-right-buttons {
23 | float: right;
24 | }
25 |
26 | .st-bottom-bar-left-buttons {
27 | position: absolute;
28 | top: 2px;
29 | left: 2px;
30 | }
31 |
32 | .st-toolbar-button-group {
33 | height: 43px;
34 | line-height: 43px;
35 |
36 | & > div {
37 | line-height: 38px;
38 | min-width: 42px;
39 | padding-top: 2px;
40 | padding-bottom: 2px;
41 | padding-left: 6px;
42 | padding-right: 6px;
43 | }
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/client/src/css/base.scss:
--------------------------------------------------------------------------------
1 | @import "common";
2 |
3 | *, *:before, *:after {
4 | box-sizing: inherit;
5 | }
6 |
7 | html {
8 | box-sizing: border-box;
9 | font-size: 14px;
10 | font-family: "HelveticaNeue-Light", "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
11 | }
12 |
13 | .noselect {
14 | -webkit-touch-callout: none; /* iOS Safari */
15 | -webkit-user-select: none; /* Chrome/Safari/Opera */
16 | -khtml-user-select: none; /* Konqueror */
17 | -moz-user-select: none; /* Firefox */
18 | -ms-user-select: none; /* Internet Explorer/Edge */
19 | user-select: none; /* Non-prefixed version, currently
20 | not supported by any browser */
21 | }
22 |
23 | .st-app ul {
24 | margin: 0;
25 | padding: 0;
26 | }
27 |
28 | .st-app li {
29 | list-style-type: none;
30 | line-height: 20px;
31 | padding-left: 4px;
32 | padding-right: 4px;
33 | }
34 |
35 | .st-app .st-small-ui li {
36 | line-height: 40px;
37 | border-bottom: 1px solid $colorBorderLight;
38 | }
--------------------------------------------------------------------------------
/client/src/css/modal.scss:
--------------------------------------------------------------------------------
1 | @import "common";
2 |
3 | .st-modal-container {
4 | position: fixed;
5 | background-color: rgba(0, 0, 0, 0.3);
6 | top: 0; right: 0; bottom: 0; left: 0;
7 |
8 | display: flex;
9 | align-items: center;
10 | justify-content: center;
11 |
12 | z-index: 1;
13 |
14 | & > * {
15 | z-index: 2;
16 | }
17 | }
18 |
19 | .st-track-info-modal {
20 | width: 300px;
21 | position: relative;
22 |
23 | .st-nav-bar {
24 | position: relative;
25 | height: 44px;
26 | line-height: 44px;
27 | text-align: center;
28 | background-color: $colorBackground2;
29 |
30 | border-top-left-radius: 5px;
31 | border-top-right-radius: 5px;
32 |
33 | .st-close-button {
34 | position: absolute;
35 | top: 0; left: 0; bottom: 0; width: 44px; line-height: 44px;
36 | cursor: pointer;
37 | font-size: 24px;
38 | }
39 | }
40 |
41 | .st-track-info {
42 | height: 300px;
43 | overflow-x: auto;
44 | overflow-y: auto;
45 |
46 | .st-table {
47 | cursor: default;
48 | }
49 | }
50 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright 2017 Stephen Johnson
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | SOFTWARE.
--------------------------------------------------------------------------------
/client/src/util/KComponent.js:
--------------------------------------------------------------------------------
1 | import { Component } from 'react';
2 | import shallowCompare from 'react-addons-shallow-compare';
3 |
4 | export default class KComponent extends Component {
5 | subscribeWhileMounted(observable, subscriber) {
6 | observable.onValue(subscriber);
7 | this.miscSubscribers.push([observable, subscriber]);
8 | }
9 |
10 | componentWillMount() {
11 | this.miscSubscribers = [];
12 |
13 | const o = this.observables ? this.observables() : {};
14 | const keys = Object.keys(o);
15 | this.subscribers = {};
16 | for (const k of keys) {
17 | const s = (v) => this.setState({[k]: v});
18 | if (!o[k] || !o[k].onValue) {
19 | throw new Error(`Key is not an observable: ${k}`);
20 | }
21 | o[k].onValue(s);
22 | this.subscribers[k] = s;
23 | }
24 | }
25 |
26 | componentWillUnmount() {
27 | const o = this.observables ? this.observables() : {};
28 | const keys = Object.keys(o);
29 | for (const k of keys) {
30 | o[k].offValue(this.subscribers[k]);
31 | }
32 | this.miscSubscribers.forEach(([obs, sub]) => {
33 | obs.offValue(sub);
34 | });
35 | }
36 |
37 | shouldComponentUpdate(nextProps, nextState) {
38 | return shallowCompare(this, nextProps, nextState);
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/client/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
16 | Summertunes
17 |
18 |
19 |
20 |
21 |
31 |
32 |
33 |
--------------------------------------------------------------------------------
/client/src/ui/Toolbar.js:
--------------------------------------------------------------------------------
1 | /* global window */
2 | import React from 'react';
3 | import NowPlaying from "./NowPlaying";
4 | import "../css/Toolbar.css";
5 | import PlaybackControls from "./PlaybackControls";
6 | import KComponent from "../util/KComponent";
7 | import { kVolume, setVolume } from "../model/playerModel";
8 |
9 | class Toolbar extends KComponent {
10 | observables() { return {
11 | volume: kVolume,
12 | }; }
13 |
14 | renderVolumeControl() {
15 | return setVolume(parseFloat(e.target.value))}
18 | value={this.state.volume} />;
19 | }
20 |
21 | renderNormal() {
22 | return
23 |
24 |
25 | {this.renderVolumeControl()}
26 |
;
27 | }
28 |
29 | renderStacked() {
30 | return
31 |
32 |
33 |
34 | {this.renderVolumeControl()}
35 |
36 |
;
37 | }
38 |
39 | render() {
40 | return this.props.stacked ? this.renderStacked() : this.renderNormal();
41 | }
42 | }
43 |
44 | Toolbar.defaultProps = {
45 | stacked: false,
46 | }
47 |
48 | export default Toolbar;
--------------------------------------------------------------------------------
/client/src/ui/PlaybackControls.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import "../css/Toolbar.css";
3 | import {
4 | kIsPlaying,
5 | kPlayingTrack,
6 | kPlaybackSeconds,
7 | setIsPlaying,
8 | goToBeginningOfTrack,
9 | goToNextTrack,
10 | goToPreviousTrack,
11 | } from "../model/playerModel";
12 | import KComponent from "../util/KComponent";
13 | import { play, pause } from "../util/svgShapes";
14 |
15 | export default class PlaybackControls extends KComponent {
16 | observables() { return {
17 | isPlaying: kIsPlaying,
18 | track: kPlayingTrack,
19 | playbackSeconds: kPlaybackSeconds,
20 | }; }
21 |
22 | play() {
23 | setIsPlaying(true);
24 | }
25 |
26 | pause() {
27 | setIsPlaying(false);
28 | }
29 |
30 | goBack() {
31 | if (this.state.playbackSeconds < 2) {
32 | goToPreviousTrack();
33 | } else {
34 | goToBeginningOfTrack();
35 | }
36 | }
37 |
38 | render() {
39 | return (
40 |
41 |
{ this.goBack(); }}>{play(true, true)}
42 | {this.state.isPlaying &&
{pause()}
}
43 | {!this.state.isPlaying &&
{play(false, false)}
}
44 |
{play(true, false)}
45 |
46 | );
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/client/src/util/react-mousetrap.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | export function mouseTrap(Base){
4 |
5 | return class extends React.Component {
6 | constructor(props){
7 | super(props);
8 | this.__mousetrapBindings = [];
9 | this.Mousetrap = require('mousetrap');
10 | }
11 |
12 | bindShortcut (key, callback) {
13 | this.Mousetrap.bind(key, callback);
14 | this.__mousetrapBindings.push(key);
15 | }
16 |
17 | unbindShortcut (key) {
18 | var index = this.__mousetrapBindings.indexOf(key);
19 |
20 | if (index > -1) {
21 | this.__mousetrapBindings.splice(index, 1);
22 | }
23 |
24 | this.Mousetrap.unbind(key);
25 | }
26 |
27 | unbindAllShortcuts () {
28 | if (this.__mousetrapBindings.length < 1) {
29 | return;
30 | }
31 |
32 | this.__mousetrapBindings.forEach((binding) => {
33 | this.Mousetrap.unbind(binding);
34 | });
35 | this.__mousetrapBindings = [];
36 | }
37 |
38 | componentWillUnmount () {
39 | this.unbindAllShortcuts();
40 | }
41 |
42 | render () {
43 | return
47 | }
48 | };
49 | }
50 |
--------------------------------------------------------------------------------
/client/src/config.js:
--------------------------------------------------------------------------------
1 | import K from "kefir";
2 | import createBus from "./model/createBus";
3 |
4 | const {hostname, protocol} = window.location;
5 |
6 | const [setServerConfig, bServerConfig] = createBus();
7 | const kServerConfig = bServerConfig.toProperty(() => ({}));
8 |
9 | window.fetch('/server_config.js')
10 | .then((result) => result.json())
11 | .then((conf) => setServerConfig(conf))
12 | .catch(() => { });
13 | window.fetch('/summertunes/server_config.js')
14 | .then((result) => result.json())
15 | .then((conf) => setServerConfig(conf))
16 | .catch(() => { });
17 |
18 | const kBeetsWebURL = bServerConfig
19 | .map(({BEETSWEB_PORT, BEETSWEB_HOST}) => {
20 | if (BEETSWEB_HOST) {
21 | return `${BEETSWEB_HOST}:${BEETSWEB_PORT}`;
22 | } else {
23 | return `${protocol}//${hostname}:${BEETSWEB_PORT}`
24 | }
25 | });
26 | const kMPVURL = bServerConfig
27 | .map(({MPV_PORT, MPV_HOST}) => {
28 | if (MPV_HOST) {
29 | return `${MPV_HOST}:${MPV_PORT}`;
30 | } else {
31 | return `${protocol}//${hostname}:${MPV_PORT}`;
32 | }
33 | });
34 | const kStaticFilesURL = K.constant('/summertunes/files')
35 | //.map(({SUMMERTUNES_PORT}) => `${protocol}//${hostname}:${3003}`);
36 | const kLastFMAPIKey = bServerConfig
37 | .map(({LAST_FM_API_KEY}) => LAST_FM_API_KEY);
38 |
39 | const kIsConfigReady = bServerConfig.map(() => true).toProperty(() => false);
40 |
41 | const kPlayerServices = kServerConfig.map(({player_services}) => player_services);
42 | kPlayerServices.onValue(() => { })
43 |
44 | export {
45 | kBeetsWebURL,
46 | kMPVURL,
47 | kStaticFilesURL,
48 | kLastFMAPIKey,
49 | kIsConfigReady,
50 | kPlayerServices,
51 | kServerConfig,
52 | };
53 |
--------------------------------------------------------------------------------
/summertunes/cli/run_mpv.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import json
3 | import logging
4 | import os
5 | import signal
6 | import sys
7 | from multiprocessing import Process, Queue
8 | from subprocess import Popen
9 |
10 | log = logging.getLogger(__name__)
11 |
12 |
13 | def _run_mpv_wrapper(pid_queue, mpv_args):
14 | log.debug(' '.join(mpv_args))
15 | proc = Popen(mpv_args)
16 | pid_queue.put(proc.pid)
17 | try:
18 | proc.communicate()
19 | except SystemExit:
20 | proc.kill()
21 | proc.kill()
22 | except KeyboardInterrupt:
23 | proc.kill()
24 | proc.kill()
25 |
26 |
27 | def wait_for_processes(pid_queue, procs):
28 | try:
29 | last_proc = None
30 | for proc in procs:
31 | proc.start()
32 | last_proc = proc
33 |
34 | if last_proc:
35 | last_proc.join()
36 | except KeyboardInterrupt:
37 | pass
38 | finally:
39 | while not pid_queue.empty():
40 | pid = pid_queue.get()
41 | log.info("Kill %d", pid)
42 | try:
43 | os.kill(pid, signal.SIGTERM)
44 | os.kill(pid, signal.SIGTERM)
45 | except ProcessLookupError:
46 | pass
47 | for p2 in procs:
48 | while p2.is_alive():
49 | p2.terminate()
50 |
51 |
52 | def run_mpv(websocket_port, socket_path):
53 | pid_queue = Queue()
54 |
55 | mpv_cmd = [
56 | sys.executable, '-m', 'summertunes.mpv2websocket',
57 | '--mpv-websocket-port', str(websocket_port),
58 | '--mpv-socket-path', str(socket_path),
59 | ]
60 | wait_for_processes(pid_queue, [
61 | Process(target=_run_mpv_wrapper, args=(pid_queue, mpv_cmd))
62 | ])
63 |
--------------------------------------------------------------------------------
/summertunes/cli/run_serve.py:
--------------------------------------------------------------------------------
1 | import json
2 | import logging
3 | import os
4 | from pathlib import Path
5 | from subprocess import Popen
6 |
7 | from flask import Flask, redirect
8 | from werkzeug.routing import PathConverter
9 |
10 | my_dir = Path(os.path.abspath(__file__)).parent
11 | STATIC_FOLDER = os.path.abspath(str(my_dir / '..' / 'static'))
12 | INNER_STATIC_FOLDER = os.path.abspath(str(Path(STATIC_FOLDER) / 'static'))
13 |
14 | log = logging.getLogger(__name__)
15 | logging.basicConfig()
16 | app = Flask(__name__)
17 |
18 | # need to register the 'everything' type before we try to define routes
19 | # that use it
20 | class EverythingConverter(PathConverter):
21 | regex = '.*?'
22 | app.url_map.converters['everything'] = EverythingConverter
23 |
24 | from summertunes.routes import summertunes_routes
25 | app.register_blueprint(summertunes_routes, url_prefix='/summertunes')
26 |
27 | @app.route("/")
28 | def r_redirect():
29 | return redirect("/", code=301)
30 |
31 |
32 | def run_serve(summertunes_port, beets_web_port, last_fm_api_key, dev, enable_mpv, mpv_websocket_port):
33 | """Serve Summertunes with the given config"""
34 | app.config['SERVER_CONFIG'] = {
35 | 'MPV_PORT': mpv_websocket_port,
36 | 'BEETSWEB_PORT': beets_web_port,
37 | 'player_services': ['web', 'mpv'] if enable_mpv else ['web'],
38 | 'LAST_FM_API_KEY': last_fm_api_key,
39 | }
40 |
41 | dev_client_path = my_dir / '..' / '..' / 'client'
42 | dev_client_public_path = dev_client_path / 'public'
43 |
44 | if dev:
45 | with (dev_client_public_path / 'server_config.js').open('w') as f_config:
46 | f_config.write(json.dumps(app.config['SERVER_CONFIG']))
47 | proc = Popen(['npm', 'start'], cwd=str(dev_client_path))
48 | proc.wait()
49 | else:
50 | app.run(host='0.0.0.0', port=summertunes_port,
51 | debug=True, threaded=True)
52 |
--------------------------------------------------------------------------------
/client/src/css/NowPlaying.scss:
--------------------------------------------------------------------------------
1 | @import "common";
2 |
3 | .st-now-playing {
4 | background-color: $colorBackground1;
5 | position: relative;
6 | height: 48px;
7 | flex-shrink: 1;
8 | margin-left: 10px;
9 | margin-right: 10px;
10 | padding-left: 46px;
11 | border: 1px solid $colorBorder;
12 |
13 | border-radius: 3px;
14 |
15 | color: $colorNowPlayingText;
16 | display: flex;
17 | flex-direction: column;
18 | flex-wrap: nowrap;
19 | align-items: center;
20 | justify-content: center;
21 |
22 | max-width: 550px;
23 | width: calc(100% - 300px);
24 |
25 | .st-toolbar-stacked & {
26 | max-width: 100%;
27 | width: 100%;
28 | }
29 |
30 | .st-now-playing-title {
31 | text-overflow: ellipsis;
32 | white-space: nowrap;
33 | overflow: hidden;
34 | max-width: calc(100% - 20px);
35 | cursor: pointer;
36 | }
37 |
38 | .st-album-art {
39 | border-radius: 5px;
40 | width: 40px;
41 | height: 40px;
42 | position: absolute;
43 | top: 3px;
44 | left: 3px;
45 |
46 | background-size: cover;
47 | background-repeat: no-repeat;
48 | background-position: center;
49 | }
50 |
51 | .st-album-art-empty {
52 | border: 1px solid $colorBorder;
53 | border-style: dashed;
54 | }
55 |
56 | .st-playback-time-bar {
57 | cursor: pointer;
58 | font-size: 12px;
59 | margin-top: 4px;
60 | width: calc(100% - 20px);
61 |
62 | display: flex;
63 | margin-left: 4px;
64 | margin-right: 4px;
65 | flex-direction: row;
66 | flex-wrap: nowrap;
67 | align-items: center;
68 | justify-content: space-between;
69 | }
70 |
71 | .st-playback-time-bar-graphic {
72 | overflow: hidden;
73 | height: 5px;
74 | border-radius: 2px;
75 | background-color: $colorBackground2;
76 |
77 | flex-grow: 1;
78 | flex-shrink: 1;
79 |
80 | & > div {
81 | background-color: $colorNowPlayingBar;
82 | height: 100%;
83 | }
84 | }
85 |
86 | .st-playback-time-bar-now {
87 | margin-right: 4px;
88 | }
89 |
90 | .st-playback-time-bar-duration {
91 | margin-left: 4px;
92 | }
93 | }
--------------------------------------------------------------------------------
/client/src/css/TrackList.scss:
--------------------------------------------------------------------------------
1 | @import "common";
2 |
3 | .st-track-list {
4 | height: 100%;
5 | overflow: auto;
6 |
7 | position: relative;
8 |
9 | .st-track-list-header-album {
10 | margin-right: 120px;
11 | }
12 |
13 | .st-track-list-header-buttons {
14 | position: absolute;
15 | top: 1em;
16 | right: 1em;
17 |
18 | & > div {
19 | cursor: pointer;
20 | line-height: 18px;
21 | padding: 2px 0;
22 | color: $colorTextLink;
23 |
24 | &:hover {
25 | text-decoration: underline;
26 | }
27 | }
28 |
29 | svg {
30 | display: block;
31 | float: left;
32 | width: 18px;
33 | height: 18px;
34 | border: 1px solid $colorBorder;
35 | border-radius: 9px;
36 | margin-right: 2px;
37 | }
38 | }
39 | }
40 |
41 | .st-track-list-empty {
42 | max-width: 100%;
43 | display: flex;
44 | flex-direction: column;
45 | align-items: center;
46 | justify-content: center;
47 | flex-wrap: nowrap;
48 |
49 | &:first-child:last-child {
50 | width: 100%;
51 | }
52 |
53 | h1, h2 {
54 | color: $colorTextFaint;
55 | text-align: center;
56 | margin-left: 1em;
57 | margin-right: 1em;
58 | }
59 |
60 | .st-pick-artist-album-prompt {
61 | margin-top: 1.4em;
62 |
63 | display: flex;
64 | flex-direction: row;
65 | align-items: center;
66 | justify-content: space-around;
67 | flex-wrap: nowrap;
68 | width: 100%;
69 |
70 | & > div {
71 | color: $colorTextLink;
72 | cursor: pointer;
73 | font-size: 1.4em;
74 | }
75 | }
76 | }
77 |
78 | .st-track-overflow-button {
79 | cursor: pointer;
80 | position: absolute;
81 | top: 0px;
82 | right: 2px;
83 | bottom: 0px;
84 | width: 16px;
85 | height: 16px;
86 | margin: auto;
87 | line-height: 14px;
88 | text-align: center;
89 | border-radius: 8px;
90 | border: 1px solid $colorBorder;
91 | font-size: 9px;
92 | background-color: $colorBackground1;
93 | color: $colorText;
94 | }
95 |
--------------------------------------------------------------------------------
/client/src/uilib/List.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import { mouseTrap } from '../util/react-mousetrap';
3 | import { kUps, kDowns } from "../model/keyboardModel";
4 | import KComponent from "../util/KComponent";
5 |
6 | class List extends KComponent {
7 | componentDidMount() {
8 | const self = this;
9 |
10 | this.subscribeWhileMounted(kUps, (e) => {
11 | if (!self.props.isKeyboardFocused) return;
12 | e.preventDefault();
13 | e.stopPropagation();
14 | if (typeof self._previousItem === "undefined") return;
15 | self.props.onClick(self._previousItem);
16 | })
17 |
18 | this.subscribeWhileMounted(kDowns, (e) => {
19 | if (!self.props.isKeyboardFocused) return;
20 | e.preventDefault();
21 | e.stopPropagation();
22 | if (typeof self._nextItem === "undefined") return;
23 | self.props.onClick(self._nextItem);
24 | })
25 | }
26 |
27 | render() {
28 | const className = `${this.props.className} noselect st-list`;
29 | const items = this.props.items || [];
30 | delete this._previousItem;
31 | delete this._nextItem;
32 |
33 | let i = 0;
34 | for (const item of items) {
35 | if (item.isSelected) {
36 | if (i > 0) {this._previousItem = items[i - 1]; }
37 | if (i < items.length - 1) {this._nextItem = items[i + 1]; }
38 | }
39 | i += 1;
40 | }
41 |
42 | return { if (this.props.ref2) this.props.ref2(el); }}
44 | className={className}
45 | style={this.props.style}>
46 | {items.map((item, i) => {
47 | return - this.props.onClick(item, i)}
49 | className={item.isSelected ? "st-list-item-selected" : ""}>
50 | {item.label}
51 |
;
52 | })}
53 |
;
54 | }
55 | }
56 |
57 | List.propTypes = {
58 | items: PropTypes.array,
59 | style: PropTypes.object,
60 | className: PropTypes.string,
61 | onClick: PropTypes.func,
62 | };
63 |
64 | List.defaultProps = {
65 | style: {},
66 | className: "",
67 | isKeyboardFocused: false,
68 | onClick: function() { },
69 | onNext: function() { },
70 | onPrevious: function() { },
71 | };
72 |
73 | export default mouseTrap(List);
74 |
--------------------------------------------------------------------------------
/client/src/css/Table.scss:
--------------------------------------------------------------------------------
1 | @import "common";
2 |
3 | .st-small-ui {
4 | .st-table {
5 | td, th {
6 | height: $heightListItemMobile;
7 | padding-left: 4px;
8 | padding-right: 4px;
9 |
10 | svg {
11 | margin-top: 9px;
12 | }
13 | }
14 | }
15 | }
16 |
17 | .st-table {
18 | cursor: pointer;
19 |
20 | table {
21 | position: absolute;
22 | border-collapse: collapse;
23 | width: 100%;
24 | border-bottom: 1px solid $colorBorder;
25 | }
26 |
27 | td, th {
28 | position: relative;
29 | border-right: 1px solid $colorBorder;
30 | padding-left: 2px;
31 | padding-right: 2px;
32 | min-width: 40px;
33 | height: $heightListItemNormal;
34 |
35 | &, & > div {
36 | text-overflow: ellipsis;
37 | overflow: hidden;
38 | white-space: nowrap;
39 | }
40 | }
41 |
42 | td:last-child, th:last-child {
43 | border-right: none;
44 | }
45 |
46 | tbody {
47 | tr {
48 | background-color: $colorTableRow;
49 |
50 | &:nth-child(even) {
51 | background-color: $colorBackground2;
52 | }
53 |
54 | &:nth-child(even) {
55 | background-color: $colorTableRowAlternate;
56 | }
57 |
58 | &.st-table-item-selected {
59 | background-color: $colorListSelectionBackground;
60 | color: $colorListSelectionText;
61 | }
62 |
63 | &.st-track-list-header {
64 | background-color: $colorTableRow;
65 | border-top: 1px solid $colorBorder;
66 |
67 | &:first-child {
68 | border-top: none;
69 | }
70 |
71 | td {
72 | padding: 10px 10px;
73 | }
74 |
75 | .st-track-list-header-album {
76 | white-space: normal;
77 | font-size: 2rem;
78 | }
79 | .st-track-list-header-artist {
80 | white-space: normal;
81 | }
82 | // .st-track-list-header-year { }
83 | }
84 |
85 | &.st-table-group-header-labels {
86 | background-color: $colorTableRow;
87 | border-bottom: 1px solid $colorBorder;
88 |
89 | td {
90 | border-right: none;
91 | }
92 | }
93 | }
94 |
95 | .st-playing-track-indicator {
96 | position: absolute;
97 | top: 0; right: 0; bottom: 0;
98 | }
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/client/src/ui/BottomBar.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import KComponent from "../util/KComponent";
3 | import {
4 | kUIConfigSetter,
5 | kUIConfigOptions,
6 | kUIConfig,
7 | } from "../model/uiModel";
8 | import { kPlayerName, setPlayerName } from "../model/playerModel";
9 | import { kPlayerServices, kServerConfig } from "../config";
10 | import "../css/BottomBar.css";
11 | import {
12 | uiConfigIconMedium,
13 | uiConfigIconLarge,
14 | } from "../util/svgShapes";
15 |
16 | const configKeyToSVG = {
17 | A: uiConfigIconMedium,
18 | B: uiConfigIconLarge,
19 | }
20 |
21 | class BottomBar extends KComponent {
22 |
23 | observables() { return {
24 | uiConfigSetter: kUIConfigSetter,
25 | uiConfigOptions: kUIConfigOptions,
26 | uiConfig: kUIConfig,
27 | playerNames: kPlayerServices,
28 | playerName: kPlayerName,
29 | config: kServerConfig,
30 | }; }
31 |
32 | render() {
33 | return
34 |
35 |
36 | {(this.state.playerNames || []).map((name) => {
37 | return (
setPlayerName(name)}
38 | key={name}
39 | className={this.state.playerName === name ? "st-toolbar-button-selected" : ""}>
40 | {{
41 | mpv: "Server",
42 | web: "Local"
43 | }[name]}
44 |
)
45 | })}
46 |
47 |
48 |
49 |
50 |
51 | {Object.keys(this.state.uiConfigOptions).sort().map((label) => {
52 | const isSelected = this.state.uiConfig === label;
53 | const className = isSelected ? "st-toolbar-button-selected" : "";
54 | const color = isSelected ? "#eee" : "#666";
55 | return (
56 |
this.state.uiConfigSetter(label)}>
59 | {configKeyToSVG[label] ? configKeyToSVG[label](36, color) : label}
60 |
61 | );
62 | })}
63 |
64 |
65 |
;
66 | }
67 | }
68 |
69 | BottomBar.defaultProps = {
70 | artistAndAlbumButtons: false,
71 | }
72 |
73 | export default BottomBar;
74 |
--------------------------------------------------------------------------------
/client/src/model/keyboardModel.js:
--------------------------------------------------------------------------------
1 | import mousetrap from "mousetrap";
2 | import createBus from "./createBus";
3 |
4 |
5 | const createBusProperty = (initialValue, skipDuplicates = true) => {
6 | const [setter, bus] = createBus();
7 | const property = (skipDuplicates ? bus.skipDuplicates() : bus).toProperty(() => initialValue);
8 | return [setter, property];
9 | }
10 |
11 | const createKeyStream = (k) => {
12 | const [set, stream] = createBus();
13 | mousetrap.bind(k, set);
14 | stream.onValue(() => { });
15 | return stream;
16 | }
17 |
18 |
19 | const keyboardFocusOptions = {
20 | artist: "artist",
21 | album: "album",
22 | trackList: "trackList",
23 | queue: "queue",
24 | }
25 | const [setKeyboardFocus, kKeyboardFocus] = createBusProperty("artist");
26 | kKeyboardFocus.log('kb').onValue(() => { });
27 |
28 |
29 | const kUps = createKeyStream(['up', 'k']);
30 | const kDowns = createKeyStream(['down', 'j']);
31 | const kLefts = createKeyStream(['left', 'h']).merge(createKeyStream('h'));
32 | const kRights = createKeyStream(['right', 'l']);
33 | const kEnters = createKeyStream(['enter', 'return']);
34 | const kSpaces = createKeyStream('space');
35 |
36 |
37 | mousetrap.bind('a', setKeyboardFocus.bind(this, keyboardFocusOptions.artist));
38 | mousetrap.bind('b', setKeyboardFocus.bind(this, keyboardFocusOptions.album));
39 | mousetrap.bind('t', setKeyboardFocus.bind(this, keyboardFocusOptions.trackList));
40 | mousetrap.bind('q', setKeyboardFocus.bind(this, keyboardFocusOptions.queue));
41 |
42 |
43 | kKeyboardFocus.sampledBy(kLefts).onValue((keyboardFocus) => {
44 | switch (keyboardFocus) {
45 | case keyboardFocusOptions.artist: break;
46 | case keyboardFocusOptions.album:
47 | setKeyboardFocus(keyboardFocusOptions.artist);
48 | break;
49 | case keyboardFocusOptions.trackList:
50 | setKeyboardFocus(keyboardFocusOptions.album);
51 | break;
52 | default: break;
53 | }
54 | });
55 |
56 |
57 | kKeyboardFocus.sampledBy(kRights).onValue((keyboardFocus) => {
58 | switch (keyboardFocus) {
59 | case keyboardFocusOptions.artist:
60 | setKeyboardFocus(keyboardFocusOptions.album)
61 | break;
62 | case keyboardFocusOptions.album:
63 | setKeyboardFocus(keyboardFocusOptions.trackList);
64 | break;
65 | case keyboardFocusOptions.trackList:
66 | break;
67 | default: break;
68 | }
69 | });
70 |
71 |
72 | export {
73 | keyboardFocusOptions,
74 | kKeyboardFocus,
75 |
76 | kUps,
77 | kDowns,
78 | kEnters,
79 | kSpaces,
80 | }
81 |
--------------------------------------------------------------------------------
/client/src/ui/NowPlaying.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import '../css/NowPlaying.css';
3 | import secondsToString from "../util/secondsToString";
4 | import KComponent from "../util/KComponent";
5 |
6 | import { seek, kPlayingTrack, kPlaybackSeconds, kAlbumArtURL } from "../model/playerModel";
7 | import { setArtist, setAlbum } from "../model/browsingModel";
8 |
9 | function percentage(fraction) {
10 | return `${fraction * 100}%`;
11 | }
12 |
13 | class NowPlaying extends KComponent {
14 | observables() { return {
15 | track: kPlayingTrack,
16 | playbackSeconds: kPlaybackSeconds,
17 | albumArtURL: kAlbumArtURL,
18 | }; }
19 |
20 | seek(e) {
21 | const fraction = e.nativeEvent.offsetX / this.playbackSecondsBar.clientWidth;
22 | seek(this.state.track.length * fraction);
23 | e.stopPropagation();
24 | }
25 |
26 | render() {
27 | const playbackFraction = this.state.track
28 | ? this.state.playbackSeconds / this.state.track.length
29 | : 0;
30 | const albumArtURL = (this.state.albumArtURL || {}).small;
31 | const albumArtStyle = albumArtURL
32 | ? {backgroundImage: `url(${albumArtURL})`}
33 | : {};
34 |
35 | /*
36 | const track = this.state.track || {
37 | album: "ALBUM",
38 | artist: "ARTIST",
39 | title: "MOST AWESOME SONG EVER",
40 | length: 100,
41 | };
42 | */
43 | const track = this.state.track;
44 |
45 | const navigateToPlayingTrack = () => {
46 | if (!track) return;
47 | setArtist(track.albumartist);
48 | setAlbum(track.album);
49 | };
50 |
51 | return
52 |
54 | {track &&
55 | {track.title}
56 | {" by "}
57 | {track.artist}
58 | {" from "}
59 | {track.album}
60 |
}
61 | {track &&
62 |
63 | {secondsToString(this.state.playbackSeconds)}
64 |
65 |
this.playbackSecondsBar = el}
67 | onClick={(e) => {this.seek(e)}} >
68 |
69 |
70 |
71 | {secondsToString(track.length)}
72 |
73 |
}
74 |
;
75 | }
76 | }
77 |
78 | NowPlaying.propTypes = {
79 | className: PropTypes.string,
80 | };
81 |
82 | NowPlaying.defaultProps = {
83 | className: "",
84 | };
85 |
86 | export default NowPlaying;
87 |
--------------------------------------------------------------------------------
/client/src/util/svgShapes.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | function play(post, flip, size=22, color="#666", offsetX=0, offsetY=0, renderPlusSign=false) {
4 | const twoPi = Math.PI * 2;
5 | const angles = [0, twoPi / 3, twoPi / 3 * 2];
6 | const numbers = [];
7 | const radius = size * 0.3;
8 | const xOffset = -2;
9 |
10 | const transform = flip ? "scale(-1, 1)" : "";
11 |
12 | for (const angle of angles) {
13 | numbers.push(
14 | Math.cos(angle) * radius + xOffset,
15 | Math.sin(angle) * radius);
16 | }
17 | const [x1, y1, x2, y2, x3, y3] = numbers; // eslint-disable-line no-unused-vars
18 | return (
19 |
31 | );
32 | }
33 |
34 | function pause(size=22, color="#666") {
35 | const w = size * 0.2;
36 | const h = size * 0.5;
37 |
38 | return (
39 |
45 | );
46 | }
47 |
48 | function uiConfigIconLarge(size=30, color="#666") {
49 | return (
50 |
55 | )
56 | }
57 |
58 | function uiConfigIconMedium(size=30, color="#666") {
59 | return (
60 |
65 | )
66 | }
67 |
68 | export {
69 | play,
70 | pause,
71 | uiConfigIconLarge,
72 | uiConfigIconMedium,
73 | }
74 |
--------------------------------------------------------------------------------
/client/src/ui/AlbumList.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import List from "../uilib/List";
3 | import KComponent from "../util/KComponent";
4 | import {
5 | kFilteredAlbums,
6 | kAlbum,
7 | setAlbum,
8 | setAlbumFilter,
9 | kAlbumFilter,
10 | } from "../model/browsingModel";
11 | import {
12 | kKeyboardFocus,
13 | keyboardFocusOptions,
14 | } from "../model/keyboardModel";
15 | // import { setOpenModal } from "../model/uiModel";
16 |
17 | class AlbumList extends KComponent {
18 | observables() { return {
19 | albums: kFilteredAlbums, selectedAlbum: kAlbum, albumFilter: kAlbumFilter,
20 | isKeyboardFocused: kKeyboardFocus.map((id) => id === keyboardFocusOptions.album),
21 | }; }
22 |
23 | componentDidMount() {
24 | this.scrollToSelection();
25 | }
26 |
27 | componentDidUpdate(prevProps, prevState) {
28 | this.scrollToSelection();
29 | }
30 |
31 | scrollToSelection() {
32 | if (!this.selectedItemIndex === null) return;
33 | const y = this.selectedItemIndex * 20;
34 |
35 | if (y >= this.listEl.scrollTop && y <= this.listEl.scrollTop + this.listEl.clientHeight - 20) {
36 | return;
37 | }
38 |
39 | if (y < this.listEl.scrollTop) {
40 | this.listEl.scrollTop = y;
41 | return;
42 | }
43 |
44 | if (y > this.listEl.scrollTop + this.listEl.clientHeight - 20) {
45 | this.listEl.scrollTop = y - this.listEl.clientHeight + 20;
46 | }
47 | }
48 |
49 | onChangeAlbumFilter(e) {
50 | setAlbumFilter(e.target.value);
51 | }
52 |
53 | render() {
54 | this.selectedItemIndex = this.state.selectedAlbum === null ? 0 : null;
55 | const listItems = [
56 | {
57 | label: "All",
58 | value: null,
59 | isSelected: this.state.selectedAlbum === null,
60 | }].concat(this.state.albums.map((album, i) => {
61 | const isSelected = ("" + album.id) === this.state.selectedAlbum;
62 | if (isSelected) this.selectedItemIndex = i + 1;
63 | return {
64 | label: `${album.album || "Unknown Album"} (${album.year})`,
65 | value: album.id,
66 | isSelected,
67 | };
68 | }));
69 |
70 | const className = "st-album-list st-app-overflowing-section " + (
71 | this.state.isKeyboardFocused ? "st-keyboard-focus" : "");
72 |
73 | return (
74 |
75 |
80 | this.listEl = el}
83 | onClick={({value}) => {
84 | setAlbum(value);
85 | // setOpenModal(null);
86 | }}
87 | items={listItems} />
88 |
89 | );
90 | }
91 | }
92 |
93 | export default AlbumList;
94 |
--------------------------------------------------------------------------------
/Readme.rst:
--------------------------------------------------------------------------------
1 | Summertunes
2 | ===========
3 |
4 | Summertunes is a web-based music player that can control mpv on a
5 | server, or play back audio in your browser.
6 |
7 | Requirements
8 | ------------
9 |
10 | Python 3.5; beets >=1.4.4; mpv
11 |
12 | .. figure:: https://www.dropbox.com/s/i1yf42p5vu7eidt/Screenshot%202017-01-17%2012.59.32.png?dl=1
13 | :alt: Screenshot
14 |
15 | Installation
16 | ------------
17 |
18 | .. code:: sh
19 |
20 | # Install mpv on your platform
21 | brew install mpv
22 | # Install summertunes from PyPI
23 | pip install summertunes
24 |
25 | Add this to your beets config (on OS X, at ``~/.config/beets/config.yaml``):
26 |
27 | .. code:: yaml
28 |
29 | plugins: web summertunes
30 | web:
31 | include_paths: true # without this, summertunes can't control mpv
32 |
33 | If you haven't used beets before, import your files into beets without
34 | rewriting tags or copying:
35 |
36 | .. code:: sh
37 |
38 | beet import -A -C /folder/of/files
39 |
40 | Running
41 | -------
42 |
43 | In terminal A, run ``beet web``:
44 |
45 | .. code:: sh
46 |
47 | beet web
48 |
49 | In terminal B, use summertunes to run mpv:
50 |
51 | .. code:: sh
52 |
53 | summertunes mpv
54 |
55 | In your web browser, visit ``http://localhost:8337/summertunes/``.
56 |
57 | **The normal ``beet web`` interface is still at
58 | ``http://localhost:8337/``. Summertunes is served at
59 | ``/summertunes/``.**
60 |
61 | Configuration
62 | -------------
63 |
64 | Summertunes is configured using your beets config file. Here are its
65 | defaults:
66 |
67 | .. code:: yaml
68 |
69 | summertunes:
70 | # port to serve mpv websocket from
71 | mpv_websocket_port: 3001
72 | # path to use for socket; should be no files with this path
73 | mpv_socket_path: /tmp/mpv_socket
74 | # show mpv in web interface? otherwise just allow web playback
75 | mpv_enabled: yes
76 | # last.fm API key, used to fetch album art
77 | last_fm_api_key: ''
78 | # if using 'summertunes serve' development server, use this port
79 | dev_server_port: 3000
80 |
81 | Developing
82 | ----------
83 |
84 | Client
85 | ~~~~~~
86 |
87 | You'll need npm installed to develop the client. To get the
88 | auto-reloading dev server running, install some stuff:
89 |
90 | .. code:: sh
91 |
92 | cd client
93 | npm install
94 | pip install -r requirements.txt
95 | pip install --editable .
96 |
97 | Update your beets config to allow CORS headers in ``beet web``:
98 |
99 | .. code:: yaml
100 |
101 | web:
102 | cors: '*'
103 | host: 0.0.0.0
104 | include_paths: true
105 |
106 | Now you can run this in one terminal to serve the JS (but not the API):
107 |
108 | .. code:: sh
109 |
110 | summertunes serve --dev # serves JS
111 |
112 | And keep ``beet web`` running in another terminal, with the config
113 | changes above, so the JS has something to talk to.
114 |
115 | Server
116 | ~~~~~~
117 |
118 | .. code:: sh
119 |
120 | pip install --editable .
121 | beet web --debug # auto-reloads when you change files
122 |
123 | Both
124 | ~~~~
125 |
126 | Run ``summertunes serve --dev`` in one terminal and ``beet web --debug``
127 | in another.
128 |
--------------------------------------------------------------------------------
/summertunes/cli/__init__.py:
--------------------------------------------------------------------------------
1 | import io
2 | import os
3 | import sys
4 | from configparser import ConfigParser
5 |
6 | import click
7 |
8 | from beets import config
9 | from beets.util import confit
10 |
11 | from summertunes.cli.run_mpv import run_mpv
12 | from summertunes.cli.run_serve import run_serve
13 | from summertunes.config_defaults import CONFIG_DEFAULTS
14 |
15 | import beetsplug.web
16 |
17 | config.resolve()
18 |
19 | CONFIG_DEFAULTS['beets_web_port'] = config['web']['port'].get(8337)
20 | for k in CONFIG_DEFAULTS:
21 | try:
22 | CONFIG_DEFAULTS[k] = config['summertunes'][k].get()
23 | except confit.NotFoundError:
24 | pass
25 |
26 | PATH_APP_DIR = click.get_app_dir('summertunes')
27 | PATH_CONFIG = os.path.join(click.get_app_dir('summertunes'), 'summertunes.conf')
28 | CONTEXT_SETTINGS = dict(
29 | help_option_names=['-h', '--help'],
30 | default_map={
31 | 'mpv': CONFIG_DEFAULTS,
32 | 'serve': CONFIG_DEFAULTS,
33 | }
34 | )
35 |
36 |
37 | def option_mpv_websocket_port(func):
38 | return click.option(
39 | '--mpv-websocket-port', default=3001, help='Port to expose mpv websocket on'
40 | )(func)
41 |
42 |
43 | @click.group(context_settings=CONTEXT_SETTINGS)
44 | @click.option(
45 | '--no-config-prompt', default=False, is_flag=True,
46 | help='If passed, never ask to create a config if none exists')
47 | @click.pass_context
48 | def cli(ctx, no_config_prompt):
49 | """
50 | Summertunes is a web interface for the Beets local music database and mpv
51 | audio/video player that gives you an iTunes-like experience in your web
52 | browser.
53 |
54 | To run Summertunes, you'll need to run two commands in separate
55 | terminals:
56 |
57 | 1. 'beet web', using the latest version of Beets (at this time, unreleased
58 | HEAD)
59 |
60 | 2. 'summertunes mpv', which runs mpv and exposes its socket interface over
61 | a websocket.
62 | """
63 |
64 |
65 | @cli.command()
66 | @option_mpv_websocket_port
67 | @click.option(
68 | '--mpv-socket-path', default='/tmp/mpv_socket',
69 | help="Path to use for mpv's UNIX socket")
70 | def mpv(mpv_websocket_port, mpv_socket_path):
71 | """Run an instance of mpv, configured to be reachable by 'summertunes serve'"""
72 | run_mpv(mpv_websocket_port, mpv_socket_path)
73 |
74 |
75 | @cli.command([])
76 | @click.option(
77 | '--dev-server-port', default=3000, help='Port to expose server on')
78 | @click.option(
79 | '--beets-web-port', default=8337, help="Port that 'beet web' is running on")
80 | @click.option(
81 | '--last-fm-api-key', default=None,
82 | help='last.fm API key for fetching album art')
83 | @click.option(
84 | '--dev/--no-dev', default=False,
85 | help='If true, run using "npm start" instead of Python static file server. Default False.')
86 | @click.option(
87 | '--mpv-enabled/--no-mpv-enabled', default=False,
88 | help="""If true, tell the client how to find the mpv websocket. Default
89 | True. Use --no-mpv-enabled if you are not running mpv.""")
90 | @option_mpv_websocket_port
91 | def serve(dev_server_port, beets_web_port, last_fm_api_key, dev, mpv_enabled, mpv_websocket_port):
92 | """Serve the Summertunes web interface"""
93 | run_serve(dev_server_port, beets_web_port, last_fm_api_key, dev, mpv_enabled, mpv_websocket_port)
94 |
--------------------------------------------------------------------------------
/client/src/css/Toolbar.scss:
--------------------------------------------------------------------------------
1 | @import "common";
2 |
3 | .st-toolbar {
4 | padding: 0 10px;
5 | height: 81px;
6 | border-bottom: 1px solid $colorBorder;
7 | flex-grow: 0;
8 | flex-shrink: 0;
9 | background-color: $colorBackground2;
10 |
11 | display: flex;
12 | flex-direction: row;
13 | flex-wrap: nowrap;
14 | align-items: center;
15 | justify-content: space-between;
16 | width: 100%;
17 |
18 | &.st-toolbar-stacked {
19 | flex-direction: column;
20 | align-items: center;
21 | justify-content: space-around;
22 | height: 100px;
23 |
24 | .st-toolbar-stacked-horz-group {
25 | display: flex;
26 | flex-direction: row;
27 | justify-content: space-between;
28 | align-items: center;
29 | flex-wrap: nowrap;
30 |
31 | & > div {
32 | margin-left: 10px;
33 |
34 | &:first-child {
35 | margin-left: 0;
36 | }
37 | }
38 | }
39 | }
40 | }
41 |
42 | .st-toolbar-button-group {
43 | color: $colorToolbarButtonText;
44 | border: 1px solid $colorBorder;
45 | background-color: $colorBackground2;
46 | border-radius: 3px;
47 | height: 24px;
48 | line-height: 24px;
49 | cursor: pointer;
50 | flex-shrink: 0;
51 | margin-left: 4px;
52 | margin-right: 4px;
53 |
54 | display: flex;
55 | flex-direction: row;
56 | flex-wrap: nowrap;
57 | align-items: stretch;
58 | overflow: hidden;
59 |
60 | & > div {
61 | flex-grow: 1;
62 | text-align: center;
63 | border-right: 1px solid $colorBorder;
64 | line-height: 22px;
65 | min-width: 22px;
66 | padding-left: 4px;
67 | padding-right: 4px;
68 |
69 | &.st-toolbar-button-selected {
70 | border-color: $colorToolbarButtonText;
71 | background-color: $colorToolbarButtonText;
72 | color: $colorListSelectionText;
73 | }
74 | }
75 | }
76 |
77 | .st-toolbar-button-group > div:last-child {
78 | border-right: none;
79 | }
80 |
81 | .st-toolbar-button-group > div:hover {
82 | background-color: $colorBorder;
83 | }
84 | .st-toolbar-button-group > div:active {
85 | background-color: $colorToolbarButtonBackgroundActive;
86 | }
87 |
88 | .st-playback-controls {
89 | width: 140px;
90 | height: 24px;
91 | }
92 |
93 | .st-search-box {
94 | width: 200px;
95 | height: 24px;
96 | flex-shrink: 1;
97 | }
98 |
99 | .st-mac-style-input {
100 | padding-left: 24px;
101 | }
102 |
103 | .st-mac-style-input::placeholder { text-align: center; transform: translateX(-12px); }
104 | .st-mac-style-input:focus::placeholder { text-align: left; transform: none; }
105 | .st-mac-style-input::-webkit-input-placeholder { text-align: center; transform: translateX(-12px); }
106 | .st-mac-style-input:focus::-webkit-input-placeholder { text-align: left; transform: none; }
107 | .st-mac-style-input::-moz-placeholder { text-align: center; transform: translateX(-12px); }
108 | .st-mac-style-input:focus::-moz-placeholder { text-align: left; transform: none; }
109 | .st-mac-style-input:-ms-input-placeholder { text-align: center; transform: translateX(-12px); }
110 | .st-mac-style-input:focus:-ms-input-placeholder { text-align: left; transform: none; }
111 | .st-mac-style-input:-moz-placeholder { text-align: center; transform: translateX(-12px); }
112 | .st-mac-style-input:focus:-moz-placeholder { text-align: left; transform: none; }
113 |
--------------------------------------------------------------------------------
/client/src/ui/ArtistList.js:
--------------------------------------------------------------------------------
1 | /* global window */
2 | import React from 'react';
3 | import List from "../uilib/List";
4 | import {
5 | kArtist,
6 | setArtist,
7 |
8 | kArtistFilter,
9 | setArtistFilter,
10 | kFilteredArtists,
11 | setAlbum,
12 | } from "../model/browsingModel";
13 | import {
14 | setSmallUIConfig,
15 | kIsSmallUI,
16 | } from "../model/uiModel";
17 | import {
18 | kKeyboardFocus,
19 | keyboardFocusOptions,
20 | } from "../model/keyboardModel";
21 | import KComponent from "../util/KComponent";
22 | // import { setOpenModal } from "../model/uiModel";
23 |
24 |
25 | class ArtistList extends KComponent {
26 | observables() { return {
27 | artists: kFilteredArtists,
28 | artist: kArtist,
29 | artistFilter: kArtistFilter,
30 | isSmallUI: kIsSmallUI,
31 | isKeyboardFocused: kKeyboardFocus.map((id) => id === keyboardFocusOptions.artist),
32 | }; }
33 |
34 | componentDidMount() {
35 | this.scrollToSelection();
36 | }
37 |
38 | componentDidUpdate(prevProps, prevState) {
39 | this.scrollToSelection();
40 | }
41 |
42 | scrollToSelection() {
43 | if (!this.selectedItemIndex === null) return;
44 | const y = this.selectedItemIndex * 20;
45 |
46 | if (y >= this.listEl.scrollTop && y <= this.listEl.scrollTop + this.listEl.clientHeight - 20) {
47 | return;
48 | }
49 |
50 | if (y < this.listEl.scrollTop) {
51 | this.listEl.scrollTop = y;
52 | return;
53 | }
54 |
55 | if (y > this.listEl.scrollTop + this.listEl.clientHeight - 20) {
56 | this.listEl.scrollTop = y - this.listEl.clientHeight + 20;
57 | }
58 | }
59 |
60 | onChangeArtistFilter(e) {
61 | setArtistFilter(e.target.value);
62 | }
63 |
64 | render() {
65 | this.selectedItemIndex = this.state.artist === null ? 0 : null;
66 | const listItems = [
67 | {
68 | label: "All",
69 | value: null,
70 | isSelected: this.state.artist === null,
71 | }].concat(this.state.artists.map((artistName, i) => {
72 | const isSelected = this.state.artist === artistName;
73 | if (isSelected) this.selectedItemIndex = i + 1;
74 | return {
75 | label: artistName,
76 | value: artistName,
77 | isSelected,
78 | };
79 | }));
80 | const onSelectItem = ({value}) => {
81 | setArtist(value);
82 | setAlbum(null);
83 | if (this.state.isSmallUI) {
84 | setSmallUIConfig('Album');
85 | }
86 | // setOpenModal(null);
87 | };
88 |
89 | const className = "st-artist-list st-app-overflowing-section " + (
90 | this.state.isKeyboardFocused ? "st-keyboard-focus" : "");
91 |
92 | return (
93 |
94 |
99 | this.listEl = el}
102 | onClick={onSelectItem}
103 | items={listItems} />
104 |
105 | );
106 | }
107 | }
108 |
109 | export default ArtistList;
110 |
--------------------------------------------------------------------------------
/client/src/model/uiModel.js:
--------------------------------------------------------------------------------
1 | import K from "kefir";
2 | import createBus from "./createBus";
3 | import localStorageJSON from "../util/localStorageJSON";
4 |
5 |
6 | const createBusProperty = (initialValue, skipDuplicates = true) => {
7 | const [setter, bus] = createBus();
8 | const property = (skipDuplicates ? bus.skipDuplicates() : bus).toProperty(() => initialValue);
9 | return [setter, property];
10 | }
11 |
12 |
13 | const MEDIUM_UI_BREAKPOINT = 600;
14 | const LARGE_UI_BREAKPOINT = 1100;
15 |
16 |
17 | const largeUIOptions = {
18 | A: [
19 | ['albumartist', 'album'],
20 | ['tracks'],
21 | ],
22 | B: [
23 | ['albumartist', 'album', 'tracks'],
24 | ],
25 | Q: [
26 | ['hierarchy', 'queue'],
27 | ],
28 | };
29 |
30 |
31 | const mediumUIOptions = largeUIOptions;
32 |
33 |
34 | const smallUIOptions = {
35 | Artist: [['albumartist']],
36 | Album: [['album']],
37 | Tracks: [['tracks']],
38 | Queue: [['queue']],
39 | };
40 |
41 |
42 | const getWindowWidth = () => window.document.body.clientWidth
43 | const kWindowWidth = K.fromEvents(window, 'resize')
44 | .map(getWindowWidth)
45 | .toProperty(getWindowWidth)
46 | const kIsMediumUI = kWindowWidth
47 | .map((width) => width >= MEDIUM_UI_BREAKPOINT && width < LARGE_UI_BREAKPOINT);
48 | const kIsLargeUI = kWindowWidth
49 | .map((width) => width >= LARGE_UI_BREAKPOINT);
50 | const kIsSmallUI = K.combine([kIsLargeUI, kIsMediumUI], (isLarge, isMedium) => {
51 | return !isLarge && !isMedium;
52 | }).toProperty(() => false);
53 |
54 |
55 | const [setIsInfoModalOpen, kIsInfoModalOpen] = createBusProperty(false);
56 | const [setInfoModalTrack, kInfoModalTrack] = createBusProperty(null);
57 | kInfoModalTrack.onValue(() => { });
58 |
59 |
60 | const openInfoModal = (track) => {
61 | setInfoModalTrack(track);
62 | setIsInfoModalOpen(true);
63 | }
64 |
65 | const closeInfoModal = () => {
66 | setIsInfoModalOpen(false);
67 | }
68 |
69 | /* ui configs */
70 |
71 | const [setLargeUIConfig, kLargeUIConfig] = createBusProperty(localStorageJSON("uiLargeUIConfig", 'B'));
72 | const [setMediumUIConfig, kMediumUIConfig] = createBusProperty(localStorageJSON("uiMediumUIConfig", 'A'));
73 | const [setSmallUIConfig, kSmallUIConfig] = createBusProperty(localStorageJSON("uiSmallUIConfig", 'Artist'));
74 |
75 | const kUIConfigSetter = K.combine([kIsLargeUI, kIsMediumUI], (isLargeUI, isMediumUI) => {
76 | if (isLargeUI) return setLargeUIConfig;
77 | if (isMediumUI) return setMediumUIConfig;
78 | return setSmallUIConfig;
79 | }).toProperty(() => setLargeUIConfig);
80 |
81 | const kUIConfigOptions = K.combine([kIsLargeUI, kIsMediumUI], (isLargeUI, isMediumUI) => {
82 | if (isLargeUI) return largeUIOptions;
83 | if (isMediumUI) return mediumUIOptions;
84 | return smallUIOptions;
85 | }).toProperty(() => largeUIOptions);
86 |
87 | const kUIConfig = K.combine([kIsLargeUI, kIsMediumUI])
88 | .flatMapLatest(([isLargeUI, isMediumUI]) => {
89 | if (isLargeUI) return kLargeUIConfig;
90 | if (isMediumUI) return kMediumUIConfig;
91 | return kSmallUIConfig;
92 | }).toProperty(() => largeUIOptions.B);
93 |
94 | /* local storage sync */
95 |
96 | kLargeUIConfig.onValue((v) => localStorage.uiLargeUIConfig = JSON.stringify(v));
97 | kMediumUIConfig.onValue((v) => localStorage.uiMediumUIConfig = JSON.stringify(v));
98 | kSmallUIConfig.onValue((v) => localStorage.uiSmallUIConfig = JSON.stringify(v));
99 |
100 | export {
101 | kIsInfoModalOpen,
102 | kInfoModalTrack,
103 | openInfoModal,
104 | closeInfoModal,
105 |
106 | kIsMediumUI,
107 | kIsLargeUI,
108 | kIsSmallUI,
109 |
110 | kUIConfigSetter,
111 | kUIConfigOptions,
112 | kUIConfig,
113 |
114 | setSmallUIConfig,
115 | }
116 |
--------------------------------------------------------------------------------
/summertunes/routes.py:
--------------------------------------------------------------------------------
1 | import imghdr
2 | import json
3 | import logging
4 | import mimetypes
5 | import os
6 | from pathlib import Path
7 |
8 | import flask
9 | from flask import g
10 | from flask import send_from_directory, abort, send_file, Blueprint
11 | import beets
12 | from beets.library import PathQuery
13 |
14 | my_dir = Path(os.path.abspath(__file__)).parent
15 | STATIC_FOLDER = os.path.abspath(str(my_dir / 'static'))
16 | INNER_STATIC_FOLDER = os.path.abspath(str(Path(STATIC_FOLDER) / 'static'))
17 |
18 | log = logging.getLogger(__name__)
19 | summertunes_routes = Blueprint(
20 | 'summertunes',
21 | 'summertunes',
22 | static_folder=INNER_STATIC_FOLDER,
23 | static_url_path='/static')
24 |
25 |
26 | def get_is_path_safe(flask_app, path):
27 | if hasattr(g, 'lib'):
28 | # if running as a beets plugin, only return files that are in beets's library
29 | query = PathQuery('path', path.encode('utf-8'))
30 | item = g.lib.items(query).get()
31 | return bool(item)
32 | else:
33 | # if not running as a beets plugin, we don't determine whether files are in the
34 | # library or not, so stick to just returning files that are "probably audio."
35 | for ext in {'mp3', 'mp4a', 'aac', 'flac', 'ogg', 'wav', 'alac'}:
36 | if path.lower().endswith("." + ext):
37 | return True
38 | return False
39 |
40 |
41 | @summertunes_routes.route('/server_config.js')
42 | def r_server_config():
43 | return json.dumps({
44 | 'MPV_PORT': beets.config['summertunes']['mpv_websocket_port'].get(),
45 | 'BEETSWEB_PORT': beets.config['web']['port'].get(),
46 | 'player_services': ['web', 'mpv'] if beets.config['summertunes']['mpv_enabled'].get() else ['web'],
47 | 'LAST_FM_API_KEY': beets.config['summertunes']['last_fm_api_key'].get(),
48 | })
49 |
50 |
51 | @summertunes_routes.route('/')
52 | def r_index():
53 | return send_from_directory(STATIC_FOLDER, 'index.html')
54 |
55 | @summertunes_routes.route('/files/')
56 | def r_send_file(path):
57 | path = '/' + path
58 |
59 | if not get_is_path_safe(flask.current_app, path):
60 | return abort(404)
61 |
62 | response = send_file(
63 | path,
64 | as_attachment=True,
65 | attachment_filename=os.path.basename(path),
66 | )
67 | response.headers['Content-Length'] = os.path.getsize(path)
68 | return response
69 |
70 |
71 | @summertunes_routes.route('/track/art/')
72 | def r_fetchart_track(path):
73 | """
74 | Fetches the album art for the song at the given path.
75 |
76 | The web plugin's /album//art endpoint is broken, not sure why.
77 | Possibly a Python 3 thing.
78 |
79 | At one point was supposed to use the 'fetchart' plugin's settings,
80 | but turns out it's just easier to look for pngs and jpgs in the
81 | file's directory.
82 | """
83 | try:
84 | if not get_is_path_safe(flask.current_app, path):
85 | return abort(404)
86 |
87 | dirpath = Path(path).parent
88 | image_path = None
89 | image_filename = None
90 | for subpath in dirpath.iterdir():
91 | if imghdr.what(str(dirpath / subpath)):
92 | image_path = str(dirpath / subpath)
93 | image_filename = str(subpath)
94 |
95 | if not os.path.exists(image_path):
96 | return abort(404)
97 |
98 | response = send_file(
99 | image_path,
100 | attachment_filename=os.path.basename(image_filename),
101 | mimetype=mimetypes.guess_type(image_path)[0])
102 | response.headers['Content-Length'] = os.path.getsize(image_path)
103 | return response
104 | except KeyError:
105 | return abort(404)
106 |
107 | @summertunes_routes.route('/')
108 | def r_files(path):
109 | return send_from_directory(STATIC_FOLDER, path)
110 |
--------------------------------------------------------------------------------
/client/src/ui/Playlist.js:
--------------------------------------------------------------------------------
1 | import React, {Component, PropTypes} from 'react';
2 | import { ContextMenuTrigger } from "react-contextmenu";
3 | import KComponent from "../util/KComponent";
4 | import Table from "../uilib/Table";
5 | import {
6 | refreshPlaylist,
7 | kPlaylistTracks,
8 | kPlaylistIndex,
9 | setPlaylistIndex,
10 | } from "../model/playerModel";
11 | import { setIsInfoModalOpen } from "../model/uiModel";
12 | import { setInfoModalTrack } from "../model/browsingModel";
13 | import { play } from "../util/svgShapes";
14 | import secondsToString from "../util/secondsToString";
15 |
16 | function collectItem ({i, item}) {
17 | return {i, item};
18 | }
19 |
20 | class PlaylistOverflowButton extends Component {
21 | render() {
22 | return (
23 |
28 | v
29 |
30 | );
31 |
32 | }
33 | }
34 | PlaylistOverflowButton.propTypes = {
35 | i: PropTypes.number.isRequired,
36 | item: PropTypes.object.isRequired,
37 | }
38 |
39 | export default class Playlist extends KComponent {
40 | constructor() {
41 | super();
42 | this.state = {trackIndex: null};
43 | }
44 |
45 | observables() {
46 | return {
47 | tracks: kPlaylistTracks,
48 | playlistIndex: kPlaylistIndex,
49 | };
50 | }
51 |
52 | componentDidMount() {
53 | refreshPlaylist();
54 | }
55 |
56 | selectedTrack() {
57 | if (this.state.trackIndex === null) return null;
58 | if (!this.state.tracks || !this.state.tracks.length) return null;
59 | return this.state.tracks[this.state.trackIndex];
60 | }
61 |
62 | onClickTrack(item, i) {
63 | if (this.state.trackIndex === i) {
64 | setPlaylistIndex(i);
65 | } else {
66 | this.setState({trackIndex: i});
67 | }
68 | }
69 |
70 | onTrackOverflow(item, i) {
71 | setInfoModalTrack(item);
72 | setIsInfoModalOpen(true);
73 | }
74 |
75 | renderEmpty() {
76 | return (
77 |
78 |
No tracks in playlist
79 |
80 | );
81 | }
82 |
83 | render() {
84 | if (!this.state.tracks || !this.state.tracks.length) return this.renderEmpty();
85 | return {
89 | return
90 | {rowIndex + 1}
91 | {rowIndex === this.state.playlistIndex && (
92 |
93 | {play(false, false, 20, this.selectedTrack() === item ? "#fff" : "#666")}
94 |
95 | )}
96 | ;
97 | }},
98 | {name: 'Title', itemKey: 'func', func: (item, columnIndex, i) => {
99 | return
100 | {item.title}
101 |
102 |
103 | }},
104 | {name: 'Album Artist', itemKey: 'albumartist'},
105 | {name: 'Album', itemKey: 'album'},
106 | {name: 'Year', itemKey: 'year'},
107 | {name: 'Time', itemKey: 'func', func: (item) => secondsToString(item.length)},
108 | ]}
109 |
110 | renderGroupHeader={(itemsInGroup, key) => null}
111 |
112 | rowFactory={(item, i, trProps, children) => (
113 |
122 | {children}
123 |
124 | )}
125 |
126 | selectedItem={this.state.trackIndex === null ? null : this.selectedTrack()}
127 | items={this.state.tracks} />;
128 | }
129 | };
130 |
--------------------------------------------------------------------------------
/client/src/css/App.scss:
--------------------------------------------------------------------------------
1 | @import "common";
2 |
3 | .st-app {
4 | position: fixed;
5 | top: 0; right: 0; bottom: 0; left: 0;
6 | &.st-app-modal { padding-bottom: 0; }
7 |
8 | display: flex;
9 | flex-direction: column;
10 | flex-wrap: nowrap;
11 | align-items: stretch;
12 | justify-content: stretch;
13 | }
14 |
15 | .st-filter-control {
16 | width: 100%;
17 | height: $heightListFilterControlNormal;
18 |
19 | .st-small-ui & {
20 | height: $heightListFilterControlMobile;
21 | }
22 | }
23 |
24 | .st-app-overflowing-section {
25 | .st-list {
26 | overflow: auto;
27 | -webkit-overflow-scrolling: touch;
28 | height: 100%;
29 |
30 | &.st-list-under-filter-control {
31 | /* scss variables don't work here */
32 | height: calc(100% - 20px);
33 |
34 | .st-small-ui & {
35 | /* scss variables don't work here */
36 | height: calc(100% - 40px);
37 | }
38 | }
39 | }
40 | }
41 |
42 | .st-list {
43 | cursor: pointer;
44 | flex-shrink: 0;
45 |
46 | li {
47 | text-overflow: ellipsis;
48 | overflow: hidden;
49 | white-space: nowrap;
50 | }
51 |
52 | .st-list-item-selected {
53 | background-color: $colorListSelectionBackground;
54 | color: $colorListSelectionText;
55 | }
56 | }
57 |
58 | .st-keyboard-focus {
59 | background-color: #eefaff;
60 | }
61 |
62 | .st-ui {
63 | position: absolute;
64 | top: 81px;
65 | bottom: 50px;
66 | left: 0; right: 0;
67 |
68 | &.st-small-ui {
69 | top: 100px;
70 | }
71 |
72 | display: flex;
73 | flex-direction: column;
74 | flex-wrap: nowrap;
75 | align-items: stretch;
76 |
77 | .st-columns-1 {
78 | overflow-x: auto;
79 | }
80 |
81 | .st-columns-2 .st-artist-list { flex-grow: 1; flex-shrink: 0.5; }
82 | .st-columns-3 .st-artist-list { max-width: 300px; }
83 | .st-columns-2 .st-album-list { flex-grow: 1; flex-shrink: 0.5; }
84 | .st-columns-3 .st-album-list { max-width: 300px; }
85 |
86 | & > div {
87 | border-bottom: 1px solid $colorBorder;
88 | &:last-child { border-bottom: none; }
89 |
90 | flex-grow: 1;
91 | flex-shrink: 1;
92 |
93 | display: flex;
94 | flex-direction: row;
95 | flex-wrap: nowrap;
96 | align-items: stretch;
97 | width: 100%;
98 |
99 | & > div {
100 | border-right: 1px solid $colorBorder;
101 | &:last-child { border-right: none; }
102 |
103 | height: 100%;
104 |
105 | &.st-album-list, &.st-artist-list {
106 | overflow-x: hidden;
107 | }
108 |
109 | &.st-artist-list {
110 | flex-shrink: 100; flex-grow: 0.1;
111 |
112 | &:first-child:last-child {
113 | max-width: 100%;
114 | width: 100%;
115 | }
116 | }
117 |
118 | &.st-album-list {
119 | flex-shrink: 10; flex-grow: 0.1;
120 |
121 | &:first-child:last-child {
122 | max-width: 100%;
123 | width: 100%;
124 | }
125 | }
126 |
127 | &.st-track-list {
128 | flex-grow: 100;
129 | flex-shrink: 0.1;
130 | min-width: 50%;
131 | }
132 | }
133 | }
134 | }
135 |
136 | .st-modal-nav-bar {
137 | height: 44px;
138 | line-height: 44px;
139 | border-bottom: 1px solid $colorBorder;
140 | flex-grow: 0;
141 | flex-shrink: 0;
142 | background-color: $colorBackground2;
143 |
144 | .st-modal-title {
145 | text-align: center;
146 | font-size: 1.2em;
147 | font-weight: bold;
148 | }
149 |
150 | .st-modal-close-button {
151 | float: left;
152 | width: 44px;
153 | height: 44px;
154 | line-height: 44px;
155 | text-align: center;
156 | cursor: pointer;
157 | font-size: 24px;
158 | }
159 | }
160 |
161 | .st-track-info {
162 | position: relative;
163 |
164 | .st-table {
165 | overflow-x: auto;
166 | overflow-y: auto;
167 | }
168 | }
169 |
170 | .react-contextmenu--visible {
171 | background-color: $colorBackground2;
172 | border: 1px solid $colorBorder;
173 |
174 | .react-contextmenu-item {
175 | cursor: pointer;
176 | border-bottom: 1px solid $colorBorder;
177 | &:last-child { border-bottom: none; }
178 | height: 20px; line-height: 20px;
179 | padding: 0 2px;
180 | &:hover {
181 | background-color: $colorBackground1;
182 | }
183 | }
184 | }
185 |
--------------------------------------------------------------------------------
/client/src/ui/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import '../css/base.css';
4 | import '../css/App.css';
5 |
6 | import BottomBar from "./BottomBar";
7 | import Toolbar from "./Toolbar";
8 | import ArtistList from "./ArtistList";
9 | import AlbumList from "./AlbumList";
10 | import TrackList from "./TrackList";
11 | import TrackInfo from "./TrackInfo";
12 | import Playlist from "./Playlist";
13 |
14 | import { ContextMenu, MenuItem } from "react-contextmenu";
15 |
16 | import { kIsConfigReady } from "../config";
17 | import { kArtist, kAlbum, kTrack } from "../model/browsingModel";
18 | import {
19 | kIsInfoModalOpen,
20 | kIsSmallUI,
21 | kUIConfig,
22 | kUIConfigOptions,
23 | closeInfoModal,
24 | openInfoModal,
25 | } from "../model/uiModel";
26 | import KComponent from "../util/KComponent";
27 |
28 | import "../css/modal.css";
29 | class Modal extends React.Component {
30 | render() {
31 | return
32 | {this.props.children}
33 |
;
34 | }
35 | }
36 |
37 |
38 | import {
39 | enqueueTrack,
40 | playTracks,
41 | removeTrackAtIndex,
42 | } from "../model/playerModel";
43 |
44 | const TrackInfoModal = () => {
45 | return (
46 |
47 |
48 |
49 | Track Info
50 |
51 | ×
52 |
53 |
54 |
55 |
56 |
57 | );
58 | }
59 |
60 | const TrackListContextMenu = () => {
61 | return (
62 |
63 |
66 |
69 |
72 |
73 | );
74 | }
75 |
76 | const PlaylistContextMenu = () => {
77 | return (
78 |
79 |
82 |
85 |
86 | );
87 | }
88 |
89 | class App extends KComponent {
90 | observables() { return {
91 | isConfigReady: kIsConfigReady,
92 |
93 | selectedArtist: kArtist,
94 | selectedAlbum: kAlbum,
95 | selectedTrack: kTrack,
96 | isInfoModalOpen: kIsInfoModalOpen,
97 |
98 | isSmallUI: kIsSmallUI,
99 | uiConfig: kUIConfig,
100 | uiConfigOptions: kUIConfigOptions,
101 | }; }
102 |
103 | render() {
104 | if (!this.state.isConfigReady) {
105 | return Loading config...
;
106 | }
107 |
108 | const config = this.state.uiConfigOptions[this.state.uiConfig];
109 |
110 | if (!config) return null;
111 | const rowHeight = `${(1 / config.length) * 100}%`;
112 | const outerClassName = (
113 | `st-rows-${config.length} ` +
114 | (this.state.isSmallUI ? "st-ui st-small-ui" : "st-ui st-large-ui")
115 | );
116 | return (
117 |
118 |
119 |
120 | {config.map((row, i) => {
121 | const innerClassName = `st-columns-${row.length}`;
122 | return
123 | {row.map((item, j) => this.configValueToComponent(item, j))}
124 |
125 | })}
126 |
127 |
128 | {this.state.isInfoModalOpen &&
}
129 |
130 |
131 |
132 |
133 | );
134 | }
135 |
136 | configValueToComponent(item, key) {
137 | switch (item) {
138 | case 'albumartist': return ;
139 | case 'album': return ;
140 | case 'tracks': return ;
141 | case 'queue': return ;
142 | default: return null;
143 | }
144 | }
145 | }
146 |
147 | export default App;
148 |
--------------------------------------------------------------------------------
/summertunes/mpv2websocket.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | import logging
4 | import time
5 | from argparse import ArgumentParser
6 | from subprocess import Popen, PIPE
7 |
8 | import eventlet
9 | from eventlet.green import socket
10 | from flask import Flask
11 | from flask_socketio import SocketIO
12 |
13 | log = logging.getLogger("mpv")
14 | logging.basicConfig(level=logging.INFO)
15 | logging.getLogger("socketio").setLevel(logging.ERROR)
16 | logging.getLogger("engineio").setLevel(logging.ERROR)
17 |
18 | try:
19 | import coloredlogs
20 | coloredlogs.install(
21 | fmt="%(asctime)s %(name)s %(levelname)s: %(message)s"
22 | )
23 | except ImportError:
24 | pass
25 |
26 | app = Flask(__name__)
27 | socketio = SocketIO(app)
28 |
29 | COMMAND_WHITELIST = {
30 | "get_property",
31 | "observe_property",
32 | "set_property",
33 |
34 | "seek",
35 | "playlist-clear",
36 | "playlist-remove",
37 | "loadfile",
38 | "playlist-next",
39 | "playlist-prev",
40 | }
41 |
42 | def _kill_socket(path):
43 | try:
44 | os.unlink(path)
45 | except OSError:
46 | if os.path.exists(path):
47 | raise
48 |
49 | def _run_mpv(socket_path):
50 | _kill_socket(socket_path)
51 |
52 | mpv_process = Popen(
53 | [
54 | 'mpv',
55 | '--quiet',
56 | '--audio-display=no',
57 | '--idle',
58 | '--gapless-audio',
59 | '--input-ipc-server', socket_path
60 | ],
61 | # block keyboard input
62 | stdin=PIPE)
63 | mpv_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) #pylint: disable=E1101
64 |
65 | return (mpv_process, mpv_socket)
66 |
67 | OBSERVED_PROPERTIES = set()
68 |
69 | @socketio.on('message')
70 | def handle_json(body):
71 | json_string = json.dumps(body)
72 | body_bytes = (json_string + '\n').encode('UTF-8')
73 | log.info("> %s", json_string)
74 |
75 | if 'command' in body and body["command"][0] == "observe_property":
76 | if body["command"][2] in OBSERVED_PROPERTIES:
77 | log.info("Skipping already observed property %s", body["command"][2])
78 | return
79 | else:
80 | OBSERVED_PROPERTIES.add(body["command"][2])
81 |
82 | if 'command' in body and body["command"][0] not in COMMAND_WHITELIST:
83 | log.warning("Skipping non-whitelisted command %s", body["command"][0])
84 | return
85 |
86 | app.config['mpv_socket'].sendall(body_bytes)
87 |
88 |
89 | def _listen_to_mpv(mpv_socket):
90 | # may or may not accurately handle partially delivered data.
91 | # also for some reason does not listen to system kill exception.
92 | spillover = ""
93 | while True:
94 | data = mpv_socket.recv(4096)
95 | lines = (spillover + data.decode('UTF-8', 'strict')).split('\n')
96 | spillover = ""
97 | for line in lines:
98 | if not line:
99 | continue
100 | if 'time-pos' not in line:
101 | log.info("< %s", line)
102 | # just forward everything raw to the websocket
103 | try:
104 | json_data = json.loads(line)
105 | socketio.send(json_data)
106 | except json.decoder.JSONDecodeError:
107 | spillover += line
108 |
109 |
110 | def _test():
111 | """
112 | body_bytes = (json.dumps({
113 | "command": [
114 | "loadfile",
115 | "/Users/stevejohnson/Music/iTunes/iTunes Media/Music/Kid Condor/Kid Condor EP/01 Imagining.mp3"
116 | ]}) + '\n').encode('UTF-8')
117 | log.info("> %r", body_bytes)
118 | mpv_socket.sendall(body_bytes)
119 | """
120 |
121 |
122 | def main(port=3001, socket_path="/tmp/mpv_socket"):
123 | mpv_process, mpv_socket = _run_mpv(socket_path)
124 | app.config['mpv_process'] = mpv_process
125 | app.config['mpv_socket'] = mpv_socket
126 | try:
127 | time.sleep(2.0) # wait for mpv to start
128 | mpv_socket.connect(socket_path)
129 | t = eventlet.spawn(_listen_to_mpv, mpv_socket)
130 | #eventlet.spawn(_test)
131 | socketio.run(app, host="0.0.0.0", port=port)
132 | finally:
133 | t.kill()
134 | mpv_process.kill()
135 | mpv_socket.close()
136 | _kill_socket(socket_path)
137 |
138 |
139 | if __name__ == '__main__':
140 | parser = ArgumentParser(
141 | description="Launches mpv and exposes its UNIX socket interface over a websocket")
142 | parser.add_argument('--mpv-websocket-port', type=int, default=3001)
143 | parser.add_argument('--mpv-socket-path', type=str, default='/tmp/mpv_socket')
144 | args = parser.parse_args()
145 | main(port=args.mpv_websocket_port, socket_path=args.mpv_socket_path)
146 |
--------------------------------------------------------------------------------
/client/src/uilib/Table.js:
--------------------------------------------------------------------------------
1 | import React, { PropTypes } from 'react';
2 | import KComponent from "../util/KComponent";
3 | import "../css/Table.css";
4 | import { kUps, kDowns } from "../model/keyboardModel";
5 |
6 | const defaultRowFactory = (item, i, props, children) => {
7 | return {children}
;
8 | }
9 |
10 | class Table extends KComponent {
11 | componentDidMount() {
12 | const self = this;
13 |
14 | this.subscribeWhileMounted(kUps, (e) => {
15 | if (!self.props.isKeyboardFocused) return;
16 | e.preventDefault();
17 | e.stopPropagation();
18 | if (!self._previousItem) return;
19 | self.props.onClick(...self._previousItem);
20 | })
21 |
22 | this.subscribeWhileMounted(kDowns, (e) => {
23 | if (!self.props.isKeyboardFocused) return;
24 | e.preventDefault();
25 | e.stopPropagation();
26 | if (!self._nextItem) return;
27 | self.props.onClick(...self._nextItem);
28 | })
29 | }
30 |
31 | inlineColumns() {
32 | return this.props.columns.filter(({groupSplitter}) => !groupSplitter);
33 | }
34 |
35 | groupSplitterColumns() {
36 | return this.props.columns.filter(({groupSplitter}) => groupSplitter);
37 | }
38 |
39 | getColumnValues(columns, item, itemIndex) {
40 | return columns
41 | .map((column, columnIndex) => {
42 | if (!item) return "";
43 | return [column, column.itemKey === 'func'
44 | ? column.func(item, columnIndex, itemIndex)
45 | : item[column.itemKey]];
46 | });
47 | }
48 |
49 | renderHeaderRow(key) {
50 | return
51 | {this.inlineColumns().map(({name, itemKey}) => (
52 | | {name} |
53 | ))}
54 |
55 | }
56 |
57 | renderBody() {
58 | const rows = [];
59 | let lastGroupKey = "";
60 | let itemsInGroup = [];
61 |
62 | const inlineColumns = this.inlineColumns();
63 | const groupSplitterColumns = this.groupSplitterColumns();
64 |
65 | let i = 0;
66 | let headerKey = 0;
67 |
68 | this._previousItem = null;
69 | this._nextItem = null;
70 |
71 | let lastItemSeen = null;
72 | let lastItemIndex = null;
73 | let lastItemWasSelected = false;
74 |
75 |
76 | if (!this.props.selectedItem && this.props.items.length) {
77 | this._nextItem = [this.props.items[0], 0];
78 | const lastItemIndex = this.props.items.length - 1;
79 | this._previousItem = [this.props.items[lastItemIndex], lastItemIndex];
80 | }
81 |
82 | const commitGroup = () => {
83 | if (!itemsInGroup.length) return;
84 | if (itemsInGroup.length) {
85 | rows.push(this.props.renderGroupHeader(itemsInGroup, "title-" + headerKey));
86 | rows.push(this.renderHeaderRow("header-" + headerKey));
87 | headerKey += 1;
88 |
89 | for (const item of itemsInGroup) {
90 | const j = i;
91 | const isSelected = item && this.props.selectedItem === item;
92 |
93 | if (isSelected) { this._previousItem = [lastItemSeen, lastItemIndex]; }
94 | if (lastItemWasSelected) { this._nextItem = [item, i]; }
95 |
96 | if (isSelected && !lastItemSeen) {
97 | this._previousItem = [item, i]; // stick at top
98 | }
99 |
100 | const trProps = {
101 | key: i,
102 | className: isSelected ? "st-table-item-selected" : "",
103 | onClick: () => this.props.onClick(item, j),
104 | };
105 |
106 | const tdComponents = this.getColumnValues(inlineColumns, item, i).map(([column, value], i) => {
107 | const itemKey = column ? `${column.itemKey}-${column.name}` : i;
108 | return {value} | ;
109 | });
110 |
111 | rows.push(this.props.rowFactory(item, i, trProps, tdComponents));
112 | lastItemSeen = item;
113 | lastItemWasSelected = isSelected;
114 | lastItemIndex = i;
115 | i++;
116 | }
117 | }
118 | itemsInGroup = [];
119 | }
120 |
121 | for (const item of this.props.items) {
122 | const itemGroupKey = JSON.stringify(this.getColumnValues(groupSplitterColumns, item));
123 | if (itemGroupKey !== lastGroupKey) {
124 | commitGroup();
125 | lastGroupKey = itemGroupKey;
126 | }
127 |
128 | itemsInGroup.push(item);
129 | }
130 | commitGroup();
131 |
132 | if (!this._nextItem) {
133 | this._nextItem = [lastItemSeen, lastItemIndex];
134 | }
135 |
136 | return {rows};
137 | }
138 |
139 | render() {
140 | return (
141 |
142 |
143 | {this.renderBody()}
144 |
145 |
146 | );
147 | }
148 | }
149 |
150 | Table.propTypes = {
151 | columns: PropTypes.array, // [{name, itemKey}]
152 | items: PropTypes.array.isRequired,
153 | renderGroupHeader: PropTypes.func,
154 | className: PropTypes.string,
155 | selectedItem: PropTypes.any,
156 | onClick: PropTypes.func,
157 | };
158 |
159 | Table.defaultProps = {
160 | className: "",
161 | onClick: () => { },
162 | renderGroupHeader: () => null,
163 | isKeyboardFocuse: false,
164 | rowFactory: defaultRowFactory,
165 | };
166 |
167 | export default Table;
168 |
--------------------------------------------------------------------------------
/client/src/model/webPlayer.js:
--------------------------------------------------------------------------------
1 | import K from "kefir";
2 | import createBus from "./createBus";
3 | import { kStaticFilesURL } from "../config";
4 | import MusicPlayer from "../util/webAudioWrapper";
5 |
6 |
7 | let URL_PREFIX = '';
8 | // a little cheap but whatever
9 | kStaticFilesURL.onValue((url) => URL_PREFIX = url);
10 |
11 |
12 | const keepAlive = (observable) => {
13 | observable.onValue(() => { });
14 | return observable;
15 | }
16 |
17 |
18 | const createBusProperty = (initialValue, skipDuplicates = true) => {
19 | const [setter, bus] = createBus();
20 | const property = (skipDuplicates ? bus.skipDuplicates() : bus).toProperty(() => initialValue);
21 | return [setter, property];
22 | }
23 |
24 |
25 | const createCallbackStream = (obj, key) => {
26 | const [setter, bus] = createBus();
27 | obj[key] = setter;
28 | return bus;
29 | }
30 |
31 |
32 | function _pathToURL(track) {
33 | const encodedPath = track.path
34 | .split('/')
35 | .map(encodeURIComponent)
36 | .join('/');
37 | return URL_PREFIX + encodedPath;
38 | }
39 |
40 |
41 | function _urlToPath(url) {
42 | return url
43 | .slice(URL_PREFIX.length)
44 | .split('/')
45 | .map(decodeURIComponent)
46 | .join('/');
47 | }
48 |
49 |
50 | class WebPlayer {
51 | constructor() {
52 | this.player = new MusicPlayer();
53 | window.player = this.player;
54 |
55 | const kSongFinished = createCallbackStream(this.player, "onSongFinished");
56 | const kPlaylistEnded = createCallbackStream(this.player, "onPlaylistEnded");
57 | const kPlayerStopped = createCallbackStream(this.player, "onPlayerStopped");
58 | const kPlayerPaused = createCallbackStream(this.player, "onPlayerPaused");
59 | const kPlayerUnpaused = createCallbackStream(this.player, "onPlayerUnpaused");
60 | const kTrackLoaded = createCallbackStream(this.player, "onTrackLoaded");
61 | const kTrackAdded = createCallbackStream(this.player, "onTrackAdded");
62 | const kTrackRemoved = createCallbackStream(this.player, "onTrackRemoved");
63 | const kVolumeChanged = createCallbackStream(this.player, "onVolumeChanged");
64 | const kMuted = createCallbackStream(this.player, "onMuted");
65 | const kUnmuted = createCallbackStream(this.player, "onUnmuted");
66 |
67 | this.kIsPlaying = keepAlive(kPlayerStopped.map(() => false)
68 | .merge(kPlayerPaused.map(() => false))
69 | .merge(kPlayerUnpaused.map(() => true))
70 | .merge(kPlayerPaused.map(() => false))
71 | .toProperty(() => false));
72 |
73 | this.kVolume = keepAlive(kVolumeChanged.toProperty(() => 1));
74 |
75 | this.kPlaylistCount = keepAlive(K.constant(0)
76 | .merge(kTrackAdded)
77 | .merge(kTrackRemoved)
78 | .merge(kPlayerUnpaused)
79 | .merge(kPlayerStopped)
80 | .merge(kSongFinished)
81 | .map(() => this.player.playlist.length)
82 | .toProperty(() => 0));
83 |
84 | this.kPlaylistPaths = keepAlive(this.kPlaylistCount
85 | .map(() => this.player.playlist.map(({path}) => _urlToPath(path))));
86 |
87 | this.kPlaylistIndex = K.constant(0); // web player keeps mutating its playlist
88 |
89 | const [observePath, kPath] = createBusProperty(null);
90 | this._observePath = observePath;
91 | this.kPath = kPath.skipDuplicates();
92 |
93 | kSongFinished.onValue(() => {
94 | this._updateTrack();
95 | })
96 |
97 | const [observePlaybackSeconds, kPlaybackSeconds] = createBusProperty(0);
98 | this.kPlaybackSeconds = kPlaybackSeconds;
99 |
100 | const updatePlaybackSeconds = () => {
101 | observePlaybackSeconds(this.player.getSongPosition());
102 | this._updateTrack();
103 |
104 | window.requestAnimationFrame(updatePlaybackSeconds);
105 | };
106 | window.requestAnimationFrame(updatePlaybackSeconds);
107 | }
108 |
109 | _updateTrack() {
110 | if (this.player.playlist.length) {
111 | this._observePath(_urlToPath(this.player.playlist[0].path));
112 | } else {
113 | this._observePath(null);
114 | }
115 | }
116 |
117 | setIsPlaying(isPlaying) {
118 | if (isPlaying) {
119 | this.player.play();
120 | } else {
121 | this.player.pause();
122 | }
123 | }
124 |
125 | setVolume(volume) {
126 | this.player.setVolume(volume);
127 | }
128 |
129 | seek(seconds) {
130 | this.player.setSongPosition(seconds);
131 | }
132 |
133 | goToBeginningOfTrack() {
134 | this.player.setSongPosition(0);
135 | }
136 |
137 | playTrack(track) {
138 | this.player.pause();
139 | this.player.removeAllTracks();
140 | this.player.addTrack(_pathToURL(track), () => {
141 | this.player.play();
142 | });
143 | }
144 |
145 | enqueueTrack(track) {
146 | this.player.addTrack(_pathToURL(track));
147 | }
148 |
149 | playTracks(tracks) {
150 | this.playTrack(tracks[0]);
151 | for (const track of tracks.slice(1)) {
152 | this.player.addTrack(_pathToURL(track));
153 | }
154 | }
155 |
156 | enqueueTracks(tracks) {
157 | for (const track of tracks) {
158 | this.player.addTrack(_pathToURL(track));
159 | }
160 | }
161 |
162 | removeTrackAtIndex(i) {
163 | this.player.removeTrack(i);
164 | }
165 |
166 | goToNextTrack() {
167 | this.player.playNext();
168 | }
169 |
170 | goToPreviousTrack() {
171 | console.error("Not implemented; web player doesn't store history");
172 | }
173 |
174 | setPlaylistIndex(i) {
175 | for (let j = 0; j < i; j++) {
176 | this.player.removeTrack(0);
177 | }
178 | }
179 |
180 | refreshPlaylist() {
181 | // playlist is always refreshed
182 | }
183 | }
184 |
185 | export default new WebPlayer();
186 |
--------------------------------------------------------------------------------
/client/src/model/playerModel.js:
--------------------------------------------------------------------------------
1 | import K from "kefir";
2 | import createBus from "./createBus";
3 | import mpvPlayer from "./mpvPlayer";
4 | import webPlayer from "./webPlayer";
5 | import { kBeetsWebURL, kLastFMAPIKey, kPlayerServices, kStaticFilesURL } from "../config";
6 | import localStorageJSON from "../util/localStorageJSON";
7 | import { kSpaces } from "../model/keyboardModel";
8 |
9 |
10 | const keepAlive = (observable) => {
11 | observable.onValue(() => { });
12 | return observable;
13 | }
14 |
15 |
16 | const playersByName = {
17 | web: webPlayer,
18 | mpv: mpvPlayer,
19 | }
20 |
21 |
22 |
23 | let _PLAYER = null;
24 | const [setPlayerName, bPlayerName] = createBus();
25 | const kPlayerName = K.combine([bPlayerName, kPlayerServices], (name, services) => {
26 | if (services.indexOf(name) > -1) return name;
27 | return 'web';
28 | }).skipDuplicates().toProperty(() => localStorageJSON("playerName", "mpv"))
29 | kPlayerName.onValue((playerName) => localStorage.playerName = JSON.stringify(playerName));
30 | const kPlayer = kPlayerName.map((name) => playersByName[name])
31 | kPlayer.onValue((p) => _PLAYER = p);
32 |
33 |
34 | const forwardPlayerProperty = (key) => {
35 | return keepAlive(kPlayer
36 | .flatMapLatest((player) => {
37 | if (!_PLAYER) return K.constant(null); // player not yet initialized
38 | if (!_PLAYER[key]) {
39 | console.error("Player is missing property", key);
40 | return K.constant(null);
41 | }
42 | return player[key];
43 | })
44 | .toProperty(() => null));
45 | };
46 |
47 |
48 | const forwardPlayerMethod = (key) => {
49 | if (!_PLAYER) return; // player not yet initialized
50 | if (!_PLAYER[key]) console.error("Player is missing method", key);
51 | return (...args) => _PLAYER[key](...args);
52 | };
53 |
54 |
55 | const trackInfoKCache = {};
56 |
57 |
58 | const createURLToKTrack = (url, path) => {
59 | if (trackInfoKCache[path]) return trackInfoKCache[path];
60 | if (!path) return K.constant(null);
61 |
62 | const property = K.fromPromise(
63 | window.fetch(`${url}/item/path/${encodeURIComponent(path)}`)
64 | .then((response) => response.json())
65 | ).toProperty(() => null);
66 | trackInfoKCache[path] = property;
67 | return property;
68 | }
69 |
70 |
71 | const createKPathToTrack = (kPathProperty) => {
72 | return K.combine([kBeetsWebURL, kPathProperty])
73 | .flatMapLatest(([url, path]) => {
74 | return createURLToKTrack(url, path);
75 | }).toProperty(() => null);
76 | };
77 |
78 |
79 | const kVolume = forwardPlayerProperty('kVolume');
80 | const kIsPlaying = forwardPlayerProperty('kIsPlaying');
81 | const kPlaybackSeconds = forwardPlayerProperty('kPlaybackSeconds');
82 | const kPath = forwardPlayerProperty('kPath');
83 | const kPlaylistCount = forwardPlayerProperty('kPlaylistCount');
84 | const kPlaylistIndex = forwardPlayerProperty('kPlaylistIndex');
85 | const kPlaylistPaths = forwardPlayerProperty('kPlaylistPaths');
86 |
87 | const kPlayingTrack = createKPathToTrack(kPath);
88 |
89 | const kPlaylistTracks = keepAlive(
90 | K.combine([kBeetsWebURL, kPlaylistPaths])
91 | .flatMapLatest(([url, paths]) => {
92 | if (!paths) return K.once([]);
93 | return K.combine(paths.map(createURLToKTrack.bind(this, url)));
94 | })
95 | .toProperty(() => []));
96 |
97 |
98 | const kLastFM = K.combine([kPlayingTrack, kLastFMAPIKey])
99 | .flatMapLatest(([track, lastFMAPIKey]) => {
100 | if (!track || !lastFMAPIKey) return K.constant(null);
101 | return K.fromPromise(window.fetch(
102 | `http://ws.audioscrobbler.com/2.0/?method=album.getinfo&api_key=${
103 | lastFMAPIKey
104 | }&artist=${
105 | track.artist
106 | }&album=${
107 | track.album
108 | }&format=json`
109 | ).then((result) => result.json())
110 | );
111 | })
112 | .toProperty(() => null);
113 |
114 | const kAlbumArtURL = K.combine([kBeetsWebURL, kPlayingTrack])
115 | .map(([url, track]) => {
116 | if (!url || !track) return {};
117 | return {small: `${url}/summertunes/track/art/${encodeURIComponent(track.path)}`};
118 | });
119 | keepAlive(kAlbumArtURL);
120 |
121 |
122 | /*
123 | const kLastFMAlbumArtURL = kLastFM
124 | .map((lastFMData) => {
125 | if (!lastFMData || !lastFMData.album) return {};
126 | const urlBySize = {};
127 | for (const imgData of (lastFMData.album.image || [])) {
128 | urlBySize[imgData.size] = imgData["#text"];
129 | }
130 | return urlBySize;
131 | keepAlive(kAlbumArtURL);
132 | */
133 |
134 |
135 | const setIsPlaying = forwardPlayerMethod('setIsPlaying');
136 | const setVolume = forwardPlayerMethod('setVolume');
137 | const seek = forwardPlayerMethod('seek');
138 | const goToBeginningOfTrack = forwardPlayerMethod('goToBeginningOfTrack');
139 | const playTrack = forwardPlayerMethod('playTrack');
140 | const enqueueTrack = forwardPlayerMethod('enqueueTrack');
141 | const removeTrackAtIndex = forwardPlayerMethod('removeTrackAtIndex');
142 | const playTracks = forwardPlayerMethod('playTracks');
143 | const enqueueTracks = forwardPlayerMethod('enqueueTracks');
144 | const goToNextTrack = forwardPlayerMethod('goToNextTrack');
145 | const goToPreviousTrack = forwardPlayerMethod('goToPreviousTrack');
146 | const refreshPlaylist = forwardPlayerMethod('refreshPlaylist');
147 | const setPlaylistIndex = forwardPlayerMethod('setPlaylistIndex');
148 |
149 |
150 | kIsPlaying.sampledBy(kSpaces).onValue((wasPlaying) => setIsPlaying(!wasPlaying));
151 | kPlayingTrack.onValue(refreshPlaylist);
152 |
153 |
154 | export {
155 | kPlayer,
156 | kPlayerName,
157 | setPlayerName,
158 |
159 | kVolume,
160 | kIsPlaying,
161 | kPlaybackSeconds,
162 | kPlayingTrack,
163 | kAlbumArtURL,
164 |
165 | kPlaylistCount,
166 | kPlaylistIndex,
167 | kPlaylistTracks,
168 |
169 | setIsPlaying,
170 | setVolume,
171 | seek,
172 | goToBeginningOfTrack,
173 | playTrack,
174 | playTracks,
175 | enqueueTrack,
176 | enqueueTracks,
177 | goToNextTrack,
178 | goToPreviousTrack,
179 | refreshPlaylist,
180 | setPlaylistIndex,
181 | removeTrackAtIndex,
182 | }
183 |
--------------------------------------------------------------------------------
/client/src/ui/TrackList.js:
--------------------------------------------------------------------------------
1 | /* global window */
2 | import "../css/TrackList.css";
3 | import React, { Component } from 'react';
4 | import Table from "../uilib/Table";
5 | import KComponent from "../util/KComponent"
6 | import secondsToString from "../util/secondsToString";
7 | import { play } from "../util/svgShapes";
8 | import { kBeetsWebURL } from "../config";
9 | import { ContextMenuTrigger } from "react-contextmenu";
10 | import {
11 | kKeyboardFocus,
12 | keyboardFocusOptions,
13 | kEnters,
14 | } from "../model/keyboardModel";
15 |
16 | import {
17 | kIsSmallUI,
18 | kUIConfigSetter,
19 | } from "../model/uiModel";
20 | import {
21 | playTracks,
22 | enqueueTracks,
23 | kPlayingTrack,
24 | } from "../model/playerModel";
25 | import {
26 | kTrackList,
27 | kTrackIndex,
28 | kPlayerQueueGetter,
29 | setTrackIndex,
30 | } from "../model/browsingModel";
31 |
32 | function areTracksEqual(a, b) {
33 | if (Boolean(a) !== Boolean(b)) return false;
34 | return a.id === b.id;
35 | }
36 |
37 | function collectTrack(props) {
38 | return {
39 | item: props.item,
40 | i: props.i,
41 | playerQueueGetter: props.playerQueueGetter,
42 | };
43 | }
44 |
45 | class TrackListOverflowButton extends Component {
46 | render() {
47 | return (
48 |
53 | v
54 |
55 | );
56 |
57 | }
58 | }
59 |
60 | class TrackList extends KComponent {
61 | observables() { return {
62 | tracks: kTrackList,
63 | trackIndex: kTrackIndex,
64 | playingTrack: kPlayingTrack,
65 | playerQueueGetter: kPlayerQueueGetter,
66 | isSmallUI: kIsSmallUI,
67 | uiConfigSetter: kUIConfigSetter,
68 | isKeyboardFocused: kKeyboardFocus.map((id) => id === keyboardFocusOptions.trackList),
69 | beetsWebURL: kBeetsWebURL,
70 | }; }
71 |
72 | componentDidMount() {
73 | this.subscribeWhileMounted(kEnters, () => {
74 | if (this.state.isKeyboardFocused && this.selectedTrack()) {
75 | playTracks(this.state.playerQueueGetter());
76 | }
77 | });
78 | }
79 |
80 | selectedTrack() {
81 | if (this.state.trackIndex === null) return null;
82 | if (!this.state.tracks || !this.state.tracks.length) return null;
83 | return this.state.tracks[this.state.trackIndex];
84 | }
85 |
86 | renderEmpty() {
87 | return (
88 |
89 |
No tracks selected
90 |
You must select at least one artist or album.
91 | {this.state.isSmallUI && (
92 |
93 |
this.state.uiConfigSetter('Artist')}>Pick artist
94 |
this.state.uiConfigSetter('Album')}>Pick album
95 |
96 | )}
97 |
98 | );
99 | }
100 |
101 | onClickItem(item, i) {
102 | const track = this.state.tracks[this.state.trackIndex]
103 | if (i === this.state.trackIndex && !areTracksEqual(track, this.state.playingTrack)) {
104 | playTracks(this.state.playerQueueGetter());
105 | } else {
106 | setTrackIndex(i);
107 | }
108 | }
109 |
110 | enqueueAlbum(playNow, itemsInGroup) {
111 | (playNow ? playTracks : enqueueTracks)(itemsInGroup);
112 | }
113 |
114 | render() {
115 | if (!this.state.tracks || !this.state.tracks.length) return this.renderEmpty();
116 |
117 | const className = "st-track-list st-app-overflowing-section " + (
118 | this.state.isKeyboardFocused ? "st-keyboard-focus" : "");
119 |
120 | return {
124 | return
125 | {item.disc}-{item.track}
126 | {this.state.playingTrack && item.id === this.state.playingTrack.id && (
127 |
128 | {play(false, false, 20, this.selectedTrack() === item ? "#fff" : "#666")}
129 |
130 | )}
131 | ;
132 | }},
133 | {name: 'Title', itemKey: 'func', func: (item, columnIndex, i) => {
134 | return
135 | {item.title}
136 |
140 |
141 | }},
142 | /*
143 | {name: 'Album Artist', itemKey: 'albumartist', groupSplitter: true},
144 | {name: 'Album', itemKey: 'album', groupSplitter: true},
145 | */
146 | {name: 'album_id', itemKey: 'album_id', groupSplitter: true},
147 | {name: 'Year', itemKey: 'year'},
148 | {name: 'Time', itemKey: 'func', func: (item) => secondsToString(item.length)},
149 | ]}
150 |
151 | renderGroupHeader={(itemsInGroup, key) => {
152 | const firstItem = itemsInGroup[0];
153 | return
154 | |
155 | {firstItem.album}
156 | {firstItem.albumartist}
157 | {firstItem.year}
158 |
159 |
160 | {play(false, false, 18, "#666", 1, -1)} Play album
161 |
162 |
163 | {play(false, false, 16, "#666", 1, -1, true)} Enqueue album
164 |
165 |
166 | |
167 |
;
168 | }}
169 |
170 | rowFactory={(item, i, trProps, children) => (
171 |
181 | {children}
182 |
183 | )}
184 |
185 | isKeyboardFocused={this.state.isKeyboardFocused}
186 | selectedItem={this.state.trackIndex === null ? null : this.selectedTrack()}
187 | items={this.state.tracks} />;
188 | }
189 | }
190 |
191 | export default TrackList;
192 |
--------------------------------------------------------------------------------
/client/src/model/browsingModel.js:
--------------------------------------------------------------------------------
1 | import K from "kefir";
2 | import { kBeetsWebURL } from "../config";
3 | import createBus from "./createBus";
4 | import parseURLQuery from "../util/parseURLQuery";
5 | import makeURLQuery from "../util/makeURLQuery";
6 |
7 | /// pass {artist, album, id}
8 | export default function albumQueryString({album_id, albumartist}) {
9 | if (album_id) {
10 | return `album_id:${album_id}`;
11 | } else {
12 | return `albumartist:${albumartist}`;
13 | }
14 | };
15 |
16 | window.K = K;
17 |
18 | /* utils */
19 |
20 | const keepAlive = (observable) => observable.onValue(() => { })
21 | const keyMapper = (k) => (obj) => obj[k] || null;
22 |
23 | /* URL data */
24 |
25 | let latestURLData = null;
26 | const getURLData = () => {
27 | if (!window.location.search) return {artist: null, album: null};
28 | latestURLData = {
29 | artist: null,
30 | album: null,
31 | ...parseURLQuery(window.location.search.slice(1)),
32 | };
33 | return latestURLData;
34 | }
35 | const [sendStatePushed, statePushes] = createBus();
36 | const kURLDataChanges = K.fromEvents(window, 'popstate')
37 | .merge(statePushes)
38 | .merge(K.constant(null))
39 | .map(getURLData);
40 |
41 | const urlUpdater = (k) => (arg) => {
42 | const newURLData = {
43 | ...latestURLData,
44 | [k]: arg,
45 | }
46 | latestURLData = newURLData;
47 | history.pushState(null, "", makeURLQuery(newURLData));
48 | sendStatePushed();
49 | }
50 |
51 | /* data */
52 |
53 | const kAllAlbums = kBeetsWebURL
54 | .flatMapLatest((url) => {
55 | return K.fromPromise(
56 | window.fetch(`${url}/album/`)
57 | .then((response) => response.json())
58 | .then(({albums}) => albums.sort((a, b) => a.album < b.album ? -1 : 1)))
59 | })
60 | .toProperty(() => []);
61 | keepAlive(kAllAlbums);
62 |
63 | const kAlbumsById = kAllAlbums
64 | .map((allAlbums) => {
65 | const val = {};
66 | for (const a of allAlbums) {
67 | val[a.id] = a;
68 | }
69 | return val;
70 | })
71 | .toProperty(() => {});
72 | keepAlive(kAlbumsById);
73 |
74 | const kAlbumsByArtist = kAllAlbums
75 | .map((albums) => {
76 | const albumsByArtist = {};
77 | for (const album of albums) {
78 | if (!albumsByArtist[album.albumartist]) {
79 | albumsByArtist[album.albumartist] = [];
80 | }
81 | albumsByArtist[album.albumartist].push(album);
82 | }
83 | for (const k of Object.keys(albumsByArtist)) {
84 | albumsByArtist[k].sort((a, b) => {
85 | if (a.year !== b.year) {
86 | return a.year > b.year ? 1 : -1;
87 | } else {
88 | return a.album > b.album ? 1 : -1;
89 | }
90 | });
91 | }
92 | return albumsByArtist;
93 | })
94 | .toProperty(() => {})
95 | keepAlive(kAllAlbums);
96 |
97 | const kArtists = kAlbumsByArtist
98 | .map((albumsByArtist) => {
99 | return Object.keys(albumsByArtist).sort((a, b) => a > b ? 1 : -1);
100 | });
101 | keepAlive(kArtists);
102 |
103 |
104 | const setArtist = urlUpdater('artist');
105 | const kArtist = kURLDataChanges.map(keyMapper('artist'))
106 | .skipDuplicates()
107 | .toProperty(() => getURLData()['artist'])
108 | keepAlive(kArtist);
109 |
110 | const kAlbums = K.combine([kAlbumsByArtist, kArtist, kAllAlbums])
111 | .map(([albumsByArtist, artistName, allAlbums]) => {
112 | if (artistName) {
113 | return albumsByArtist[artistName] || [];
114 | } else {
115 | return allAlbums;
116 | }
117 | })
118 | .toProperty(() => []);
119 | keepAlive(kAlbums);
120 |
121 | const setAlbum = urlUpdater('album');
122 | const kAlbum = kArtist.map(() => null).skip(1) // don't zap initial load
123 | .merge(kURLDataChanges.map(keyMapper('album')))
124 | .skipDuplicates()
125 | .toProperty(() => getURLData()['album'])
126 | keepAlive(kAlbum);
127 |
128 | function getTrackList(beetsWebURL, album_id, albumartist) {
129 | if (!album_id && !albumartist) return new Promise((resolve, reject) => {
130 | resolve([]);
131 | });
132 | const url = `${beetsWebURL}/item/query/${albumQueryString({albumartist, album_id})}`;
133 | return window.fetch(url)
134 | .then((response) => response.json())
135 | .then(({results}) => results)
136 | }
137 |
138 | const kTrackList = K.combine([kBeetsWebURL, kArtist, kAlbum])
139 | .flatMapLatest(([beetsWebURL, artist, album]) => {
140 | return K.fromPromise(getTrackList(beetsWebURL, album, artist));
141 | })
142 | .toProperty(() => []);
143 |
144 | const [setTrackIndex, bTrackIndex] = createBus()
145 | const kTrackIndex = bTrackIndex
146 | .merge(kTrackList.changes().map(() => null))
147 | .toProperty(() => null);
148 | keepAlive(kTrackIndex);
149 |
150 | const kTrack = K.combine([kTrackList, kTrackIndex], (trackList, trackIndex) => {
151 | if (trackIndex === null) return null;
152 | if (trackList.length < 1) return null;
153 | if (trackIndex >= trackList.length) return null;
154 | return trackList[trackIndex];
155 | }).toProperty(() => null);
156 | keepAlive(kTrack);
157 |
158 | const kPlayerQueueGetter = K.combine([kTrackList, kTrackIndex], (trackList, trackIndex) => {
159 | return (overrideTrackIndex = null) => {
160 | const actualTrackIndex = overrideTrackIndex === null ? trackIndex : overrideTrackIndex;
161 | if (actualTrackIndex === null) return [];
162 | if (trackList.length < 1) return [];
163 | if (actualTrackIndex >= trackList.length) return [];
164 | return trackList.slice(actualTrackIndex);
165 | }
166 | }).toProperty(() => () => []);
167 | keepAlive(kPlayerQueueGetter);
168 |
169 | /* filterable artists/albums */
170 |
171 | const [setArtistFilter, bArtistFilter] = createBus();
172 | const kArtistFilter = bArtistFilter.toProperty(() => "");
173 | const [setAlbumFilter, bAlbumFilter] = createBus();
174 | const kAlbumFilter = bAlbumFilter.toProperty(() => "");
175 |
176 | const kFilteredArtists = K.combine([kArtists, kArtistFilter.debounce(300)], (artists, filter) => {
177 | filter = filter.toLocaleLowerCase();
178 | if (!filter) return artists;
179 | if (!artists) return [];
180 | return artists.filter((a) => a.toLocaleLowerCase().indexOf(filter) > -1);
181 | }).toProperty(() => []);
182 |
183 | const kFilteredAlbums = K.combine([kAlbums, kAlbumFilter.debounce(300)], (albums, filter) => {
184 | filter = filter.toLocaleLowerCase();
185 | if (!filter) return albums;
186 | if (!albums) return [];
187 | return albums.filter((a) => a.album.toLocaleLowerCase().indexOf(filter) > -1);
188 | }).toProperty(() => []);;
189 |
190 | /* page title update */
191 |
192 | K.combine([kURLDataChanges.merge(K.constant(null)), kArtist, kAlbum, kAlbumsById])
193 | .toProperty(() => [null, null, null, {}])
194 | .map(([_, artist, albumId, albumsById]) => {
195 | if (albumId && albumsById[albumId]) {
196 | const album = albumsById[albumId];
197 | return `Summertunes – ${album.album} – ${album.albumartist}`;
198 | } else if (artist) {
199 | return `Summertunes – ${artist}`;
200 | } else {
201 | return "Summertunes";
202 | }
203 | })
204 | .onValue((title) => document.title = title);
205 |
206 |
207 | export {
208 | kArtists,
209 | kArtist,
210 | kAlbums,
211 | kAlbum,
212 | kTrackList,
213 | kTrackIndex,
214 | kTrack,
215 | kPlayerQueueGetter,
216 |
217 | setArtist,
218 | setAlbum,
219 | setTrackIndex,
220 | getTrackList,
221 |
222 | setArtistFilter,
223 | kArtistFilter,
224 | kFilteredArtists,
225 | setAlbumFilter,
226 | kAlbumFilter,
227 | kFilteredAlbums,
228 | }
229 |
--------------------------------------------------------------------------------
/client/src/model/mpvPlayer.js:
--------------------------------------------------------------------------------
1 |
2 | /* global console */
3 | /* global window */
4 | import io from 'socket.io-client';
5 | import K from "kefir";
6 | import { kMPVURL } from "../config";
7 | import createBus from "./createBus";
8 |
9 |
10 | const LOG = false;
11 |
12 |
13 | const keepAlive = (observable) => {
14 | observable.onValue(() => { });
15 | return observable;
16 | }
17 |
18 |
19 | class MPVPlayer {
20 | constructor(kSocketURL) {
21 | this.ready = false;
22 | this.requestIdToPropertyName = {};
23 |
24 | /* events */
25 | [this.sendEvent, this.events] = createBus();
26 |
27 | if (LOG) {
28 | this.events.filter((e) => {
29 | return e.event !== "property-change" || e.name !== "time-pos";
30 | }).log("mpv");
31 | } else {
32 | keepAlive(this.events);
33 |
34 | }
35 |
36 | this.kPropertyChanges = this.events
37 | .map((event) => {
38 | if (!event.request_id) {
39 | // console.debug(event);
40 | return event;
41 | }
42 | if (!this.requestIdToPropertyName[event.request_id]) return event;
43 | const name = this.requestIdToPropertyName[event.request_id];
44 | if (!name) {
45 | console.error("Couldn't decode response", event);
46 | };
47 | delete this.requestIdToPropertyName[event.request_id];
48 | const reconstructedEvent = {
49 | "event": "property-change",
50 | "name": name,
51 | "data": event.data,
52 | }
53 | return reconstructedEvent;
54 | })
55 | .filter((event) => {
56 | return event.event === "property-change";
57 | })
58 | .map(({name, data}) => {
59 | return {name, data};
60 | });
61 |
62 | this.kPath = keepAlive(this.kPropertyChanges
63 | .filter(({name}) => name === "path")
64 | .map(({data}) => data)
65 | .skipDuplicates()
66 | .toProperty(() => null));
67 |
68 | this.kVolume = keepAlive(this.kPropertyChanges
69 | .filter(({name}) => name === "volume")
70 | .map(({data}) => data / 100)
71 | .skipDuplicates()
72 | .toProperty(() => 1));
73 |
74 | this.kIsPlaying = keepAlive(this.kPropertyChanges
75 | .filter(({name}) => name === "pause")
76 | .map(({data}) => !data)
77 | .skipDuplicates()
78 | .toProperty(() => false));
79 |
80 | this.kPlaybackSeconds = keepAlive(this.kPropertyChanges
81 | .filter(({name}) => name === "time-pos")
82 | .map(({data}) => data)
83 | .toProperty(() => 0));
84 |
85 | this.kPlaylistCount = keepAlive(this.kPropertyChanges
86 | .filter(({name}) => name === "playlist/count")
87 | .map(({data}) => data)
88 | .toProperty(() => 0));
89 |
90 | this.kPlaylistIndex = keepAlive(this.kPropertyChanges
91 | .filter(({name}) => name === "playlist-pos")
92 | .map(({data}) => data)
93 | .toProperty(() => 0));
94 |
95 | this.kPlaylistPaths = keepAlive(this.kPlaylistCount
96 | .flatMapLatest((count) => {
97 | const missing = {};
98 | const numbers = [];
99 | for (let i = 0; i < count; i++) {
100 | numbers.push(i);
101 | missing[i] = true;
102 | this.getProperty(`playlist/${i}/filename`);
103 | const j = i;
104 | setTimeout(() => {
105 | if (missing[j]) {
106 | console.warn("Re-fetching missing playlist filename", j);
107 | this.getProperty(`playlist/${j}/filename`);
108 | }
109 | }, 500);
110 | }
111 | return K.combine(numbers.map((i) => {
112 | return this.kPropertyChanges
113 | .filter(({name}) => name === `playlist/${i}/filename`)
114 | .take(1)
115 | .map(({data}) => {
116 | delete missing[i];
117 | return data;
118 | })
119 | }));
120 | }).toProperty(() => []));
121 |
122 | kSocketURL.onValue((url) => this.initSocket(url));
123 | }
124 |
125 | initSocket(socketURL) {
126 | this.socket = io(socketURL);
127 |
128 | // get_property doesn't include the property name in the return value, so
129 | // we need to do this silly request_id thing
130 | this.i = 0;
131 |
132 | /* setup */
133 | this.socket.on('connect', () => {
134 | console.log("socket.io connected"); // eslint-disable-line no-console
135 | this.sendAndObserve("path");
136 | this.sendAndObserve("pause");
137 | this.sendAndObserve("time-pos");
138 | this.sendAndObserve("volume");
139 | this.sendAndObserve("playlist-pos");
140 | });
141 | this.socket.on('disconnect', () => {
142 | console.warn("socket.io disconnected"); // eslint-disable-line no-console
143 | });
144 |
145 | K.fromEvents(this.socket, 'message').onValue(this.sendEvent);
146 |
147 | this.ready = true;
148 | }
149 |
150 | send(args) {
151 | if (this.socket) {
152 | if (LOG) console.debug(">", JSON.stringify(args));
153 | this.socket.send(args);
154 | }
155 | }
156 |
157 | getProperty(propertyName) {
158 | this.i += 1;
159 | this.requestIdToPropertyName[this.i] = propertyName;
160 | this.send({"command": ["get_property", propertyName], "request_id": this.i});
161 | }
162 |
163 | sendAndObserve(propertyName) {
164 | this.send({"command": ["observe_property", 0, propertyName]})
165 | this.getProperty(propertyName);
166 | }
167 |
168 | setIsPlaying(isPlaying) {
169 | this.send({"command": ["set_property", "pause", !isPlaying]});
170 | this.getProperty("pause"); // sometimes this can get unsynced; make sure we don't get stuck!
171 | }
172 |
173 | setVolume(volume) {
174 | this.send({"command": ["set_property", "volume", volume * 100]});
175 | }
176 |
177 | seek(seconds) {
178 | this.send({"command": ["seek", seconds, "absolute"]});
179 | }
180 |
181 | goToBeginningOfTrack() {
182 | this.seek(0);
183 | }
184 |
185 | playTrack(track) {
186 | this.send({"command": ["playlist-clear"]});
187 | this.send({"command": ["playlist-remove", "current"]});
188 | this.send({"command": ["loadfile", track.path, "append-play"]});
189 | this.setIsPlaying(true);
190 | }
191 |
192 | enqueueTrack(track) {
193 | this.send({"command": ["loadfile", track.path, "append"]});
194 | }
195 |
196 | playTracks(tracks) {
197 | this.send({"command": ["playlist-clear"]});
198 | this.send({"command": ["playlist-remove", "current"]});
199 | //this.send({"command": ["stop"]});
200 | this.send({"command": ["loadfile", tracks[0].path, "append-play"]});
201 | tracks.slice(1).forEach((track) => {
202 | this.send({"command": ["loadfile", track.path, "append"]});
203 | });
204 | }
205 |
206 | enqueueTracks(tracks) {
207 | tracks.forEach((track) => {
208 | this.send({"command": ["loadfile", track.path, "append"]});
209 | });
210 | }
211 |
212 | goToPreviousTrack() {
213 | this.send({"command": ["playlist-prev", "force"]});
214 | }
215 |
216 | goToNextTrack() {
217 | this.send({"command": ["playlist-next", "force"]});
218 | }
219 |
220 | setPlaylistIndex(i) {
221 | this.send({"command": ["set_property", "playlist-pos", i]});
222 | }
223 |
224 | refreshPlaylist() {
225 | this.getProperty('playlist/count');
226 | }
227 |
228 | removeTrackAtIndex(i) {
229 | this.send({"command": ["playlist-remove", i]});
230 | this.refreshPlaylist();
231 | }
232 | }
233 |
234 | export default new MPVPlayer(kMPVURL);
235 |
--------------------------------------------------------------------------------
/client/src/util/webAudioWrapper.js:
--------------------------------------------------------------------------------
1 | const requestAudio = function(path, callback) {
2 | var request;
3 | request = new XMLHttpRequest();
4 | request.open('GET', path, true);
5 | request.responseType = 'arraybuffer';
6 | request.onload = function() {
7 | var audioData;
8 | audioData = request.response;
9 | return callback(audioData);
10 | };
11 | return request.send();
12 | };
13 |
14 | class MusicTrack {
15 | constructor(player, path1, onended, onloaded) {
16 | this.paused = false;
17 | this.stopped = true;
18 | this.soundStart = 0;
19 | this.pauseOffset = 0;
20 | this.player = player;
21 | this.path = path1;
22 | this.onended = onended;
23 | this.onloaded = onloaded;
24 | requestAudio(this.path, (audioData) => {
25 | return this.player.ctx.decodeAudioData(audioData, (decodedData) => {
26 | this.buffer = decodedData;
27 | this.initializeSource();
28 | this.onloaded();
29 | });
30 | });
31 | }
32 |
33 | initializeSource() {
34 | this.source = this.player.ctx.createBufferSource();
35 | this.source.connect(this.player.gainNode);
36 | this.source.buffer = this.buffer;
37 | return this.source.onended = this.onended;
38 | };
39 |
40 | play() {
41 | if (!this.paused && this.stopped) {
42 | this.soundStart = Date.now();
43 | this.source.onended = this.onended;
44 | this.source.start();
45 | return this.stopped = false;
46 | } else if (this.paused) {
47 | this.paused = false;
48 | this.source.onended = this.onended;
49 | return this.source.start(0, this.pauseOffset / 1000);
50 | }
51 | };
52 |
53 | stop() {
54 | if (!this.stopped) {
55 | this.source.onended = null;
56 | try {
57 | this.source.stop();
58 | } catch (e) {
59 | // whatever
60 | }
61 | this.stopped = true;
62 | this.paused = false;
63 | return this.initializeSource();
64 | }
65 | };
66 |
67 | pause() {
68 | if (!(this.paused || this.stopped)) {
69 | this.pauseOffset = Date.now() - this.soundStart;
70 | this.paused = true;
71 | this.source.onended = null;
72 | this.source.stop();
73 | return this.initializeSource();
74 | }
75 | };
76 |
77 | getDuration() {
78 | return this.buffer.duration;
79 | };
80 |
81 | getPosition() {
82 | if (this.paused) {
83 | return this.pauseOffset / 1000;
84 | } else if (this.stopped) {
85 | return 0;
86 | } else {
87 | return (Date.now() - this.soundStart) / 1000;
88 | }
89 | };
90 |
91 | setPosition(position) {
92 | if (!this.buffer) return;
93 | if (position < this.buffer.duration) {
94 | if (this.paused) {
95 | return this.pauseOffset = position;
96 | } else if (this.stopped) {
97 | this.stopped = false;
98 | this.soundStart = Date.now() - position * 1000;
99 | this.source.onended = this.onended;
100 | return this.source.start(0, position);
101 | } else {
102 | this.source.onended = null;
103 | this.source.stop();
104 | this.initializeSource();
105 | this.soundStart = Date.now() - position * 1000;
106 | return this.source.start(0, position);
107 | }
108 | } else {
109 | throw new Error("Cannot play further the end of the track");
110 | }
111 | };
112 | }
113 |
114 | class MusicPlayer {
115 | constructor() {
116 | this.playlist = [];
117 | this.muted = false;
118 |
119 | this.onSongFinished = function(path) { };
120 | this.onPlaylistEnded = function() { };
121 | this.onPlayerStopped = function() { };
122 | this.onPlayerPaused = function() { };
123 | this.onPlayerUnpaused = function() { };
124 | this.onTrackLoaded = function(path) { };
125 | this.onTrackAdded = function(path) { };
126 | this.onTrackRemoved = function(path) { };
127 | this.onVolumeChanged = function(value) { };
128 | this.onMuted = function() { };
129 | this.onUnmuted = function() { };
130 | this.ctx = new (window.AudioContext || window.webkitAudioContext)();
131 | this.gainNode = this.ctx.createGain();
132 | this.gainNode.connect(this.ctx.destination);
133 |
134 | /* stupid iOS magic */
135 | window.addEventListener('touchstart', () => {
136 | // create empty buffer
137 | var buffer = this.ctx.createBuffer(1, 1, 22050);
138 | var source = this.ctx.createBufferSource();
139 | source.buffer = buffer;
140 | // connect to output (your speakers)
141 | source.connect(this.ctx.destination);
142 | // play the file
143 | try {
144 | source.noteOn(0);
145 | } catch (e) {
146 | source.start(0);
147 | }
148 | }, false);
149 | }
150 |
151 | setVolume(value) {
152 | this.gainNode.gain.value = value;
153 | this.onVolumeChanged(value);
154 | };
155 |
156 | getVolume() {
157 | return this.gainNode.gain.value;
158 | };
159 |
160 | toggleMute() {
161 | if (this.muted) {
162 | this.muted = false;
163 | this.gainNode.gain.value = this.previousGain;
164 | this.onUnmuted();
165 | } else {
166 | this.previousGain = this.gainNode.gain.value;
167 | this.gainNode.gain.value = 0;
168 | this.muted = true;
169 | this.onMuted();
170 | }
171 | };
172 |
173 | pause() {
174 | if (this.playlist.length !== 0) {
175 | this.playlist[0].pause();
176 | this.onPlayerPaused();
177 | }
178 | };
179 |
180 | stop() {
181 | if (this.playlist.length !== 0) {
182 | this.playlist[0].stop();
183 | this.onPlayerStopped();
184 | }
185 | };
186 |
187 | play() {
188 | if (this.playlist.length !== 0) {
189 | this.playlist[0].play();
190 | return this.onPlayerUnpaused();
191 | }
192 | };
193 |
194 | playNext() {
195 | if (this.playlist.length !== 0) {
196 | const oldTrack = this.playlist[0];
197 | oldTrack.stop();
198 | this.playlist.shift();
199 | if (this.playlist.length === 0) {
200 | return this.onPlaylistEnded();
201 | } else {
202 | this.onTrackRemoved(oldTrack.path)
203 | return this.playlist[0].play();
204 | }
205 | }
206 | };
207 |
208 | addTrack(path, onLoad) {
209 | const finishedCallback = () => {
210 | this.onSongFinished(path);
211 | return this.playNext();
212 | };
213 | const loadedCallback = () => {
214 | if (onLoad) onLoad();
215 | return this.onTrackLoaded(path);
216 | };
217 | return this.playlist.push(new MusicTrack(this, path, finishedCallback, loadedCallback));
218 | };
219 |
220 | insertTrack(index, path) {
221 | const finishedCallback = () => {
222 | this.onSongFinished(path);
223 | return this.playNext();
224 | };
225 | const loadedCallback = () => {
226 | return this.onTrackLoaded(path);
227 | };
228 | return this.playlist.splice(index, 0, new MusicTrack(this, path, finishedCallback, loadedCallback));
229 | };
230 |
231 | removeTrack(index) {
232 | var song;
233 | song = this.playlist.splice(index, 1);
234 | return this.onTrackRemoved(song.path);
235 | };
236 |
237 | replaceTrack(index, path) {
238 | const finishedCallback = () => {
239 | this.onSongFinished(path);
240 | return this.playNext();
241 | };
242 | const loadedCallback = () => {
243 | return this.onTrackLoaded(path);
244 | };
245 | const newTrack = new MusicTrack(this, path, finishedCallback, loadedCallback);
246 | const oldTrack = this.playlist.splice(index, 1, newTrack);
247 | return this.onTrackRemoved(oldTrack.path);
248 | };
249 |
250 | getSongDuration(index) {
251 | if (this.playlist.length === 0) {
252 | return 0;
253 | } else {
254 | if (index != null) {
255 | return this.playlist[index] ? this.playlist[index].getDuration() : 0;
256 | } else {
257 | return this.playlist[0].getDuration();
258 | }
259 | }
260 | };
261 |
262 | getSongPosition() {
263 | if (this.playlist.length === 0) {
264 | return 0;
265 | } else {
266 | return this.playlist[0].getPosition();
267 | }
268 | };
269 |
270 | setSongPosition(value) {
271 | if (this.playlist.length !== 0) {
272 | return this.playlist[0].setPosition(value);
273 | }
274 | };
275 |
276 | removeAllTracks() {
277 | this.stop();
278 | this.playlist = [];
279 | };
280 | }
281 |
282 | export default MusicPlayer;
283 |
--------------------------------------------------------------------------------
/.pylintrc:
--------------------------------------------------------------------------------
1 | [MASTER]
2 |
3 | # Specify a configuration file.
4 | #rcfile=
5 |
6 | # Python code to execute, usually for sys.path manipulation such as
7 | # pygtk.require().
8 | #init-hook=
9 |
10 | # Profiled execution.
11 | profile=no
12 |
13 | # Add files or directories to the blacklist. They should be base names, not
14 | # paths.
15 | ignore=CVS .git .hg
16 |
17 | # Pickle collected data for later comparisons.
18 | persistent=yes
19 |
20 | # List of plugins (as comma separated values of python modules names) to load,
21 | # usually to register additional checkers.
22 | load-plugins=
23 |
24 |
25 | [MESSAGES CONTROL]
26 |
27 | # Enable the message, report, category or checker with the given id(s). You can
28 | # either give multiple identifier separated by comma (,) or put this option
29 | # multiple time.
30 | #enable=
31 |
32 | # Disable the message, report, category or checker with the given id(s). You
33 | # can either give multiple identifier separated by comma (,) or put this option
34 | # multiple time (only on the command line, not in the configuration file where
35 | # it should appear only once).
36 |
37 | # Brain-dead errors regarding standard language features
38 | # W0142 = *args and **kwargs support
39 | # W0403 = Relative imports
40 |
41 | # Pointless whinging
42 | # R0201 = Method could be a function
43 | # W0212 = Accessing protected attribute of client class
44 | # W0613 = Unused argument
45 | # W0232 = Class has no __init__ method
46 | # R0903 = Too few public methods
47 | # C0301 = Line too long
48 | # R0913 = Too many arguments
49 | # C0103 = Invalid name
50 | # R0914 = Too many local variables
51 |
52 | # PyLint's module importation is unreliable
53 | # F0401 = Unable to import module
54 | # W0402 = Uses of a deprecated module
55 |
56 | # Already an error when wildcard imports are used
57 | # W0614 = Unused import from wildcard
58 |
59 | # Sometimes disabled depending on how bad a module is
60 | # C0111 = Missing docstring
61 |
62 | # Disable the message(s) with the given id(s).
63 | disable=W0142,W0403,R0201,W0212,W0613,W0232,R0903,W0614,C0111,C0301,R0913,C0103,F0401,W0402,R0914,I0011
64 |
65 | [REPORTS]
66 |
67 | # Set the output format. Available formats are text, parseable, colorized, msvs
68 | # (visual studio) and html
69 | output-format=text
70 |
71 | # Include message's id in output
72 | include-ids=no
73 |
74 | # Put messages in a separate file for each module / package specified on the
75 | # command line instead of printing them on stdout. Reports (if any) will be
76 | # written in a file name "pylint_global.[txt|html]".
77 | files-output=no
78 |
79 | # Tells whether to display a full report or only the messages
80 | reports=yes
81 |
82 | # Python expression which should return a note less than 10 (10 is the highest
83 | # note). You have access to the variables errors warning, statement which
84 | # respectively contain the number of errors / warnings messages and the total
85 | # number of statements analyzed. This is used by the global evaluation report
86 | # (RP0004).
87 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)
88 |
89 | # Add a comment according to your evaluation note. This is used by the global
90 | # evaluation report (RP0004).
91 | comment=no
92 |
93 |
94 | [BASIC]
95 |
96 | # Required attributes for module, separated by a comma
97 | required-attributes=
98 |
99 | # List of builtins function names that should not be used, separated by a comma
100 | bad-functions=map,filter,apply,input
101 |
102 | # Regular expression which should only match correct module names
103 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$
104 |
105 | # Regular expression which should only match correct module level names
106 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$
107 |
108 | # Regular expression which should only match correct class names
109 | class-rgx=[A-Z_][a-zA-Z0-9]+$
110 |
111 | # Regular expression which should only match correct function names
112 | function-rgx=[a-z_][a-z0-9_]{2,30}$
113 |
114 | # Regular expression which should only match correct method names
115 | method-rgx=[a-z_][a-z0-9_]{2,30}$
116 |
117 | # Regular expression which should only match correct instance attribute names
118 | attr-rgx=[a-z_][a-z0-9_]{2,30}$
119 |
120 | # Regular expression which should only match correct argument names
121 | argument-rgx=[a-z_][a-z0-9_]{2,30}$
122 |
123 | # Regular expression which should only match correct variable names
124 | variable-rgx=[a-z_][a-z0-9_]{2,30}$
125 |
126 | # Regular expression which should only match correct list comprehension /
127 | # generator expression variable names
128 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$
129 |
130 | # Good variable names which should always be accepted, separated by a comma
131 | good-names=i,j,k,ex,Run,_
132 |
133 | # Bad variable names which should always be refused, separated by a comma
134 | bad-names=foo,bar,baz,toto,tutu,tata
135 |
136 | # Regular expression which should only match functions or classes name which do
137 | # not require a docstring
138 | no-docstring-rgx=__.*__
139 |
140 |
141 | [FORMAT]
142 |
143 | # Maximum number of characters on a single line.
144 | max-line-length=80
145 |
146 | # Maximum number of lines in a module
147 | max-module-lines=1000
148 |
149 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
150 | # tab).
151 | indent-string=' '
152 |
153 |
154 | [MISCELLANEOUS]
155 |
156 | # List of note tags to take in consideration, separated by a comma.
157 | notes=FIXME,XXX,TODO
158 |
159 |
160 | [SIMILARITIES]
161 |
162 | # Minimum lines number of a similarity.
163 | min-similarity-lines=4
164 |
165 | # Ignore comments when computing similarities.
166 | ignore-comments=yes
167 |
168 | # Ignore docstrings when computing similarities.
169 | ignore-docstrings=yes
170 |
171 |
172 | [TYPECHECK]
173 |
174 | # Tells whether missing members accessed in mixin class should be ignored. A
175 | # mixin class is detected if its name ends with "mixin" (case insensitive).
176 | ignore-mixin-members=yes
177 |
178 | # List of classes names for which member attributes should not be checked
179 | # (useful for classes with attributes dynamically set).
180 | ignored-classes=SQLObject
181 |
182 | # When zope mode is activated, add a predefined set of Zope acquired attributes
183 | # to generated-members.
184 | zope=no
185 |
186 | # List of members which are set dynamically and missed by pylint inference
187 | # system, and so shouldn't trigger E0201 when accessed. Python regular
188 | # expressions are accepted.
189 | generated-members=REQUEST,acl_users,aq_parent
190 |
191 |
192 | [VARIABLES]
193 |
194 | # Tells whether we should check for unused import in __init__ files.
195 | init-import=no
196 |
197 | # A regular expression matching the beginning of the name of dummy variables
198 | # (i.e. not used).
199 | dummy-variables-rgx=_|dummy
200 |
201 | # List of additional names supposed to be defined in builtins. Remember that
202 | # you should avoid to define new builtins when possible.
203 | additional-builtins=
204 |
205 |
206 | [CLASSES]
207 |
208 | # List of interface methods to ignore, separated by a comma. This is used for
209 | # instance to not check methods defines in Zope's Interface base class.
210 | ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by
211 |
212 | # List of method names used to declare (i.e. assign) instance attributes.
213 | defining-attr-methods=__init__,__new__,setUp
214 |
215 |
216 | [DESIGN]
217 |
218 | # Maximum number of arguments for function / method
219 | max-args=5
220 |
221 | # Argument names that match this expression will be ignored. Default to name
222 | # with leading underscore
223 | ignored-argument-names=_.*
224 |
225 | # Maximum number of locals for function / method body
226 | max-locals=15
227 |
228 | # Maximum number of return / yield for function / method body
229 | max-returns=6
230 |
231 | # Maximum number of branch for function / method body
232 | max-branchs=12
233 |
234 | # Maximum number of statements in function / method body
235 | max-statements=50
236 |
237 | # Maximum number of parents for a class (see R0901).
238 | max-parents=7
239 |
240 | # Maximum number of attributes for a class (see R0902).
241 | max-attributes=7
242 |
243 | # Minimum number of public methods for a class (see R0903).
244 | min-public-methods=2
245 |
246 | # Maximum number of public methods for a class (see R0904).
247 | max-public-methods=20
248 |
249 |
250 | [IMPORTS]
251 |
252 | # Deprecated modules which should not be used, separated by a comma
253 | deprecated-modules=regsub,string,TERMIOS,Bastion,rexec
254 |
255 | # Create a graph of every (i.e. internal and external) dependencies in the
256 | # given file (report RP0402 must not be disabled)
257 | import-graph=
258 |
259 | # Create a graph of external dependencies in the given file (report RP0402 must
260 | # not be disabled)
261 | ext-import-graph=
262 |
263 | # Create a graph of internal dependencies in the given file (report RP0402 must
264 | # not be disabled)
265 | int-import-graph=
266 |
--------------------------------------------------------------------------------
/client/src/css/normalize.scss:
--------------------------------------------------------------------------------
1 | /*! normalize.css v5.0.0 | MIT License | github.com/necolas/normalize.css */
2 |
3 | /**
4 | * 1. Change the default font family in all browsers (opinionated).
5 | * 2. Correct the line height in all browsers.
6 | * 3. Prevent adjustments of font size after orientation changes in
7 | * IE on Windows Phone and in iOS.
8 | */
9 |
10 | /* Document
11 | ========================================================================== */
12 |
13 | html {
14 | font-family: sans-serif; /* 1 */
15 | line-height: 1.15; /* 2 */
16 | -ms-text-size-adjust: 100%; /* 3 */
17 | -webkit-text-size-adjust: 100%; /* 3 */
18 | }
19 |
20 | /* Sections
21 | ========================================================================== */
22 |
23 | /**
24 | * Remove the margin in all browsers (opinionated).
25 | */
26 |
27 | body {
28 | margin: 0;
29 | }
30 |
31 | /**
32 | * Add the correct display in IE 9-.
33 | */
34 |
35 | article,
36 | aside,
37 | footer,
38 | header,
39 | nav,
40 | section {
41 | display: block;
42 | }
43 |
44 | /**
45 | * Correct the font size and margin on `h1` elements within `section` and
46 | * `article` contexts in Chrome, Firefox, and Safari.
47 | */
48 |
49 | h1 {
50 | font-size: 2em;
51 | margin: 0.67em 0;
52 | }
53 |
54 | /* Grouping content
55 | ========================================================================== */
56 |
57 | /**
58 | * Add the correct display in IE 9-.
59 | * 1. Add the correct display in IE.
60 | */
61 |
62 | figcaption,
63 | figure,
64 | main { /* 1 */
65 | display: block;
66 | }
67 |
68 | /**
69 | * Add the correct margin in IE 8.
70 | */
71 |
72 | figure {
73 | margin: 1em 40px;
74 | }
75 |
76 | /**
77 | * 1. Add the correct box sizing in Firefox.
78 | * 2. Show the overflow in Edge and IE.
79 | */
80 |
81 | hr {
82 | box-sizing: content-box; /* 1 */
83 | height: 0; /* 1 */
84 | overflow: visible; /* 2 */
85 | }
86 |
87 | /**
88 | * 1. Correct the inheritance and scaling of font size in all browsers.
89 | * 2. Correct the odd `em` font sizing in all browsers.
90 | */
91 |
92 | pre {
93 | font-family: monospace, monospace; /* 1 */
94 | font-size: 1em; /* 2 */
95 | }
96 |
97 | /* Text-level semantics
98 | ========================================================================== */
99 |
100 | /**
101 | * 1. Remove the gray background on active links in IE 10.
102 | * 2. Remove gaps in links underline in iOS 8+ and Safari 8+.
103 | */
104 |
105 | a {
106 | background-color: transparent; /* 1 */
107 | -webkit-text-decoration-skip: objects; /* 2 */
108 | }
109 |
110 | /**
111 | * Remove the outline on focused links when they are also active or hovered
112 | * in all browsers (opinionated).
113 | */
114 |
115 | a:active,
116 | a:hover {
117 | outline-width: 0;
118 | }
119 |
120 | /**
121 | * 1. Remove the bottom border in Firefox 39-.
122 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
123 | */
124 |
125 | abbr[title] {
126 | border-bottom: none; /* 1 */
127 | text-decoration: underline; /* 2 */
128 | text-decoration: underline dotted; /* 2 */
129 | }
130 |
131 | /**
132 | * Prevent the duplicate application of `bolder` by the next rule in Safari 6.
133 | */
134 |
135 | b,
136 | strong {
137 | font-weight: inherit;
138 | }
139 |
140 | /**
141 | * Add the correct font weight in Chrome, Edge, and Safari.
142 | */
143 |
144 | b,
145 | strong {
146 | font-weight: bolder;
147 | }
148 |
149 | /**
150 | * 1. Correct the inheritance and scaling of font size in all browsers.
151 | * 2. Correct the odd `em` font sizing in all browsers.
152 | */
153 |
154 | code,
155 | kbd,
156 | samp {
157 | font-family: monospace, monospace; /* 1 */
158 | font-size: 1em; /* 2 */
159 | }
160 |
161 | /**
162 | * Add the correct font style in Android 4.3-.
163 | */
164 |
165 | dfn {
166 | font-style: italic;
167 | }
168 |
169 | /**
170 | * Add the correct background and color in IE 9-.
171 | */
172 |
173 | mark {
174 | background-color: #ff0;
175 | color: #000;
176 | }
177 |
178 | /**
179 | * Add the correct font size in all browsers.
180 | */
181 |
182 | small {
183 | font-size: 80%;
184 | }
185 |
186 | /**
187 | * Prevent `sub` and `sup` elements from affecting the line height in
188 | * all browsers.
189 | */
190 |
191 | sub,
192 | sup {
193 | font-size: 75%;
194 | line-height: 0;
195 | position: relative;
196 | vertical-align: baseline;
197 | }
198 |
199 | sub {
200 | bottom: -0.25em;
201 | }
202 |
203 | sup {
204 | top: -0.5em;
205 | }
206 |
207 | /* Embedded content
208 | ========================================================================== */
209 |
210 | /**
211 | * Add the correct display in IE 9-.
212 | */
213 |
214 | audio,
215 | video {
216 | display: inline-block;
217 | }
218 |
219 | /**
220 | * Add the correct display in iOS 4-7.
221 | */
222 |
223 | audio:not([controls]) {
224 | display: none;
225 | height: 0;
226 | }
227 |
228 | /**
229 | * Remove the border on images inside links in IE 10-.
230 | */
231 |
232 | img {
233 | border-style: none;
234 | }
235 |
236 | /**
237 | * Hide the overflow in IE.
238 | */
239 |
240 | svg:not(:root) {
241 | overflow: hidden;
242 | }
243 |
244 | /* Forms
245 | ========================================================================== */
246 |
247 | /**
248 | * 1. Change the font styles in all browsers (opinionated).
249 | * 2. Remove the margin in Firefox and Safari.
250 | */
251 |
252 | button,
253 | input,
254 | optgroup,
255 | select,
256 | textarea {
257 | font-family: sans-serif; /* 1 */
258 | font-size: 100%; /* 1 */
259 | line-height: 1.15; /* 1 */
260 | margin: 0; /* 2 */
261 | }
262 |
263 | /**
264 | * Show the overflow in IE.
265 | * 1. Show the overflow in Edge.
266 | */
267 |
268 | button,
269 | input { /* 1 */
270 | overflow: visible;
271 | }
272 |
273 | /**
274 | * Remove the inheritance of text transform in Edge, Firefox, and IE.
275 | * 1. Remove the inheritance of text transform in Firefox.
276 | */
277 |
278 | button,
279 | select { /* 1 */
280 | text-transform: none;
281 | }
282 |
283 | /**
284 | * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
285 | * controls in Android 4.
286 | * 2. Correct the inability to style clickable types in iOS and Safari.
287 | */
288 |
289 | button,
290 | html [type="button"], /* 1 */
291 | [type="reset"],
292 | [type="submit"] {
293 | -webkit-appearance: button; /* 2 */
294 | }
295 |
296 | /**
297 | * Remove the inner border and padding in Firefox.
298 | */
299 |
300 | button::-moz-focus-inner,
301 | [type="button"]::-moz-focus-inner,
302 | [type="reset"]::-moz-focus-inner,
303 | [type="submit"]::-moz-focus-inner {
304 | border-style: none;
305 | padding: 0;
306 | }
307 |
308 | /**
309 | * Restore the focus styles unset by the previous rule.
310 | */
311 |
312 | button:-moz-focusring,
313 | [type="button"]:-moz-focusring,
314 | [type="reset"]:-moz-focusring,
315 | [type="submit"]:-moz-focusring {
316 | outline: 1px dotted ButtonText;
317 | }
318 |
319 | /**
320 | * Change the border, margin, and padding in all browsers (opinionated).
321 | */
322 |
323 | fieldset {
324 | border: 1px solid #c0c0c0;
325 | margin: 0 2px;
326 | padding: 0.35em 0.625em 0.75em;
327 | }
328 |
329 | /**
330 | * 1. Correct the text wrapping in Edge and IE.
331 | * 2. Correct the color inheritance from `fieldset` elements in IE.
332 | * 3. Remove the padding so developers are not caught out when they zero out
333 | * `fieldset` elements in all browsers.
334 | */
335 |
336 | legend {
337 | box-sizing: border-box; /* 1 */
338 | color: inherit; /* 2 */
339 | display: table; /* 1 */
340 | max-width: 100%; /* 1 */
341 | padding: 0; /* 3 */
342 | white-space: normal; /* 1 */
343 | }
344 |
345 | /**
346 | * 1. Add the correct display in IE 9-.
347 | * 2. Add the correct vertical alignment in Chrome, Firefox, and Opera.
348 | */
349 |
350 | progress {
351 | display: inline-block; /* 1 */
352 | vertical-align: baseline; /* 2 */
353 | }
354 |
355 | /**
356 | * Remove the default vertical scrollbar in IE.
357 | */
358 |
359 | textarea {
360 | overflow: auto;
361 | }
362 |
363 | /**
364 | * 1. Add the correct box sizing in IE 10-.
365 | * 2. Remove the padding in IE 10-.
366 | */
367 |
368 | [type="checkbox"],
369 | [type="radio"] {
370 | box-sizing: border-box; /* 1 */
371 | padding: 0; /* 2 */
372 | }
373 |
374 | /**
375 | * Correct the cursor style of increment and decrement buttons in Chrome.
376 | */
377 |
378 | [type="number"]::-webkit-inner-spin-button,
379 | [type="number"]::-webkit-outer-spin-button {
380 | height: auto;
381 | }
382 |
383 | /**
384 | * 1. Correct the odd appearance in Chrome and Safari.
385 | * 2. Correct the outline style in Safari.
386 | */
387 |
388 | [type="search"] {
389 | -webkit-appearance: textfield; /* 1 */
390 | outline-offset: -2px; /* 2 */
391 | }
392 |
393 | /**
394 | * Remove the inner padding and cancel buttons in Chrome and Safari on macOS.
395 | */
396 |
397 | [type="search"]::-webkit-search-cancel-button,
398 | [type="search"]::-webkit-search-decoration {
399 | -webkit-appearance: none;
400 | }
401 |
402 | /**
403 | * 1. Correct the inability to style clickable types in iOS and Safari.
404 | * 2. Change font properties to `inherit` in Safari.
405 | */
406 |
407 | ::-webkit-file-upload-button {
408 | -webkit-appearance: button; /* 1 */
409 | font: inherit; /* 2 */
410 | }
411 |
412 | /* Interactive
413 | ========================================================================== */
414 |
415 | /*
416 | * Add the correct display in IE 9-.
417 | * 1. Add the correct display in Edge, IE, and Firefox.
418 | */
419 |
420 | details, /* 1 */
421 | menu {
422 | display: block;
423 | }
424 |
425 | /*
426 | * Add the correct display in all browsers.
427 | */
428 |
429 | summary {
430 | display: list-item;
431 | }
432 |
433 | /* Scripting
434 | ========================================================================== */
435 |
436 | /**
437 | * Add the correct display in IE 9-.
438 | */
439 |
440 | canvas {
441 | display: inline-block;
442 | }
443 |
444 | /**
445 | * Add the correct display in IE.
446 | */
447 |
448 | template {
449 | display: none;
450 | }
451 |
452 | /* Hidden
453 | ========================================================================== */
454 |
455 | /**
456 | * Add the correct display in IE 10-.
457 | */
458 |
459 | [hidden] {
460 | display: none;
461 | }
462 |
--------------------------------------------------------------------------------
/summertunes/static/static/css/main.3bd60646.css:
--------------------------------------------------------------------------------
1 | *,:after,:before{box-sizing:inherit}html{box-sizing:border-box;font-size:14px;font-family:HelveticaNeue-Light,Helvetica Neue Light,Helvetica Neue,Helvetica,Arial,Lucida Grande,sans-serif}.noselect{-webkit-touch-callout:none;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.st-app ul{margin:0;padding:0}.st-app li{list-style-type:none;line-height:20px;padding-left:4px;padding-right:4px}.st-app .st-small-ui li{line-height:40px;border-bottom:1px solid #eee}.st-app{position:fixed;top:0;right:0;bottom:0;left:0;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap;-webkit-box-align:stretch;-webkit-align-items:stretch;-ms-flex-align:stretch;align-items:stretch;-webkit-box-pack:stretch;-webkit-justify-content:stretch;-ms-flex-pack:stretch;justify-content:stretch}.st-app.st-app-modal{padding-bottom:0}.st-filter-control{width:100%;height:20px}.st-small-ui .st-filter-control{height:40px}.st-app-overflowing-section .st-list{overflow:auto;-webkit-overflow-scrolling:touch;height:100%}.st-app-overflowing-section .st-list.st-list-under-filter-control{height:calc(100% - 20px)}.st-small-ui .st-app-overflowing-section .st-list.st-list-under-filter-control{height:calc(100% - 40px)}.st-list{cursor:pointer;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0}.st-list li{text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.st-list .st-list-item-selected{background-color:#3c68d6;color:#fff}.st-keyboard-focus{background-color:#eefaff}.st-ui{position:absolute;top:81px;bottom:50px;left:0;right:0;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap;-webkit-box-align:stretch;-webkit-align-items:stretch;-ms-flex-align:stretch;align-items:stretch}.st-ui.st-small-ui{top:100px}.st-ui .st-columns-1{overflow-x:auto}.st-ui .st-columns-2 .st-artist-list{-webkit-box-flex:1;-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1;-webkit-flex-shrink:0.5;-ms-flex-negative:0.5;flex-shrink:0.5}.st-ui .st-columns-3 .st-artist-list{max-width:300px}.st-ui .st-columns-2 .st-album-list{-webkit-box-flex:1;-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1;-webkit-flex-shrink:0.5;-ms-flex-negative:0.5;flex-shrink:0.5}.st-ui .st-columns-3 .st-album-list{max-width:300px}.st-ui>div{border-bottom:1px solid #ddd;-webkit-box-flex:1;-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1;-webkit-flex-shrink:1;-ms-flex-negative:1;flex-shrink:1;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap;-webkit-box-align:stretch;-webkit-align-items:stretch;-ms-flex-align:stretch;align-items:stretch;width:100%}.st-ui>div:last-child{border-bottom:none}.st-ui>div>div{border-right:1px solid #ddd;height:100%}.st-ui>div>div:last-child{border-right:none}.st-ui>div>div.st-album-list,.st-ui>div>div.st-artist-list{overflow-x:hidden}.st-ui>div>div.st-artist-list{-webkit-flex-shrink:100;-ms-flex-negative:100;flex-shrink:100;-webkit-box-flex:0.1;-webkit-flex-grow:0.1;-ms-flex-positive:0.1;flex-grow:0.1}.st-ui>div>div.st-artist-list:first-child:last-child{max-width:100%;width:100%}.st-ui>div>div.st-album-list{-webkit-flex-shrink:10;-ms-flex-negative:10;flex-shrink:10;-webkit-box-flex:0.1;-webkit-flex-grow:0.1;-ms-flex-positive:0.1;flex-grow:0.1}.st-ui>div>div.st-album-list:first-child:last-child{max-width:100%;width:100%}.st-ui>div>div.st-track-list{-webkit-box-flex:100;-webkit-flex-grow:100;-ms-flex-positive:100;flex-grow:100;-webkit-flex-shrink:0.1;-ms-flex-negative:0.1;flex-shrink:0.1;min-width:50%}.st-modal-nav-bar{height:44px;line-height:44px;border-bottom:1px solid #ddd;-webkit-box-flex:0;-webkit-flex-grow:0;-ms-flex-positive:0;flex-grow:0;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0;background-color:#eee}.st-modal-nav-bar .st-modal-title{text-align:center;font-size:1.2em;font-weight:700}.st-modal-nav-bar .st-modal-close-button{float:left;width:44px;height:44px;line-height:44px;text-align:center;cursor:pointer;font-size:24px}.st-track-info{position:relative}.st-track-info .st-table{overflow-x:auto;overflow-y:auto}.react-contextmenu--visible{background-color:#eee;border:1px solid #ddd}.react-contextmenu--visible .react-contextmenu-item{cursor:pointer;border-bottom:1px solid #ddd;height:20px;line-height:20px;padding:0 2px}.react-contextmenu--visible .react-contextmenu-item:last-child{border-bottom:none}.react-contextmenu--visible .react-contextmenu-item:hover{background-color:#fff}.st-bottom-bar{position:absolute;right:0;bottom:0;left:0;height:50px;padding:4px;border-top:1px solid #ddd;-webkit-box-flex:0;-webkit-flex-grow:0;-ms-flex-positive:0;flex-grow:0;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0;background-color:#eee;width:100%;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:end;-webkit-justify-content:flex-end;-ms-flex-pack:end;justify-content:flex-end}.st-bottom-bar .st-bottom-bar-right-buttons{float:right}.st-bottom-bar .st-bottom-bar-left-buttons{position:absolute;top:2px;left:2px}.st-bottom-bar .st-toolbar-button-group{height:43px;line-height:43px}.st-bottom-bar .st-toolbar-button-group>div{line-height:38px;min-width:42px;padding:2px 6px}.st-now-playing{background-color:#fff;position:relative;height:48px;-webkit-flex-shrink:1;-ms-flex-negative:1;flex-shrink:1;margin-left:10px;margin-right:10px;padding-left:46px;border:1px solid #ddd;border-radius:3px;color:#444;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-webkit-justify-content:center;-ms-flex-pack:center;justify-content:center;max-width:550px;width:calc(100% - 300px)}.st-toolbar-stacked .st-now-playing{max-width:100%;width:100%}.st-now-playing .st-now-playing-title{text-overflow:ellipsis;white-space:nowrap;overflow:hidden;max-width:calc(100% - 20px);cursor:pointer}.st-now-playing .st-album-art{border-radius:5px;width:40px;height:40px;position:absolute;top:3px;left:3px;background-size:cover;background-repeat:no-repeat;background-position:50%}.st-now-playing .st-album-art-empty{border:1px solid #ddd;border-style:dashed}.st-now-playing .st-playback-time-bar{cursor:pointer;font-size:12px;margin-top:4px;width:calc(100% - 20px);display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;margin-left:4px;margin-right:4px;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between}.st-now-playing .st-playback-time-bar-graphic{overflow:hidden;height:5px;border-radius:2px;background-color:#eee;-webkit-box-flex:1;-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1;-webkit-flex-shrink:1;-ms-flex-negative:1;flex-shrink:1}.st-now-playing .st-playback-time-bar-graphic>div{background-color:#3c68d6;height:100%}.st-now-playing .st-playback-time-bar-now{margin-right:4px}.st-now-playing .st-playback-time-bar-duration{margin-left:4px}.st-toolbar{padding:0 10px;height:81px;border-bottom:1px solid #ddd;-webkit-box-flex:0;-webkit-flex-grow:0;-ms-flex-positive:0;flex-grow:0;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0;background-color:#eee;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap;-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;width:100%}.st-toolbar,.st-toolbar.st-toolbar-stacked{-webkit-box-direction:normal;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center}.st-toolbar.st-toolbar-stacked{-webkit-box-orient:vertical;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;-webkit-justify-content:space-around;-ms-flex-pack:distribute;justify-content:space-around;height:100px}.st-toolbar.st-toolbar-stacked .st-toolbar-stacked-horz-group{display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;-webkit-box-pack:justify;-webkit-justify-content:space-between;-ms-flex-pack:justify;justify-content:space-between;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap}.st-toolbar.st-toolbar-stacked .st-toolbar-stacked-horz-group>div{margin-left:10px}.st-toolbar.st-toolbar-stacked .st-toolbar-stacked-horz-group>div:first-child{margin-left:0}.st-toolbar-button-group{color:#666;border:1px solid #ddd;background-color:#eee;border-radius:3px;height:24px;line-height:24px;cursor:pointer;-webkit-flex-shrink:0;-ms-flex-negative:0;flex-shrink:0;margin-left:4px;margin-right:4px;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap;-webkit-box-align:stretch;-webkit-align-items:stretch;-ms-flex-align:stretch;align-items:stretch;overflow:hidden}.st-toolbar-button-group>div{-webkit-box-flex:1;-webkit-flex-grow:1;-ms-flex-positive:1;flex-grow:1;text-align:center;border-right:1px solid #ddd;line-height:22px;min-width:22px;padding-left:4px;padding-right:4px}.st-toolbar-button-group>div.st-toolbar-button-selected{border-color:#666;background-color:#666;color:#fff}.st-toolbar-button-group>div:last-child{border-right:none}.st-toolbar-button-group>div:hover{background-color:#ddd}.st-toolbar-button-group>div:active{background-color:#ccc}.st-playback-controls{width:140px;height:24px}.st-search-box{width:200px;height:24px;-webkit-flex-shrink:1;-ms-flex-negative:1;flex-shrink:1}.st-mac-style-input{padding-left:24px}.st-mac-style-input::placeholder{text-align:center;-webkit-transform:translateX(-12px);-ms-transform:translateX(-12px);transform:translateX(-12px)}.st-mac-style-input:focus::placeholder{text-align:left;-webkit-transform:none;-ms-transform:none;transform:none}.st-mac-style-input::-webkit-input-placeholder{text-align:center;-webkit-transform:translateX(-12px);transform:translateX(-12px)}.st-mac-style-input:focus::-webkit-input-placeholder{text-align:left;-webkit-transform:none;transform:none}.st-mac-style-input::-moz-placeholder{text-align:center;transform:translateX(-12px)}.st-mac-style-input:focus::-moz-placeholder{text-align:left;transform:none}.st-mac-style-input:-ms-input-placeholder{text-align:center;-ms-transform:translateX(-12px);transform:translateX(-12px)}.st-mac-style-input:focus:-ms-input-placeholder{text-align:left;-ms-transform:none;transform:none}.st-mac-style-input:-moz-placeholder{text-align:center;transform:translateX(-12px)}.st-mac-style-input:focus:-moz-placeholder{text-align:left;transform:none}.st-track-list{height:100%;overflow:auto;position:relative}.st-track-list .st-track-list-header-album{margin-right:120px}.st-track-list .st-track-list-header-buttons{position:absolute;top:1em;right:1em}.st-track-list .st-track-list-header-buttons>div{cursor:pointer;line-height:18px;padding:2px 0;color:#3c68d6}.st-track-list .st-track-list-header-buttons>div:hover{text-decoration:underline}.st-track-list .st-track-list-header-buttons svg{display:block;float:left;width:18px;height:18px;border:1px solid #ddd;border-radius:9px;margin-right:2px}.st-track-list-empty{max-width:100%;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-webkit-flex-direction:column;-ms-flex-direction:column;flex-direction:column;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-webkit-justify-content:center;-ms-flex-pack:center;justify-content:center;-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap}.st-track-list-empty:first-child:last-child{width:100%}.st-track-list-empty h1,.st-track-list-empty h2{color:#ddd;text-align:center;margin-left:1em;margin-right:1em}.st-track-list-empty .st-pick-artist-album-prompt{margin-top:1.4em;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-webkit-flex-direction:row;-ms-flex-direction:row;flex-direction:row;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;-webkit-justify-content:space-around;-ms-flex-pack:distribute;justify-content:space-around;-webkit-flex-wrap:nowrap;-ms-flex-wrap:nowrap;flex-wrap:nowrap;width:100%}.st-track-list-empty .st-pick-artist-album-prompt>div{color:#3c68d6;cursor:pointer;font-size:1.4em}.st-track-overflow-button{cursor:pointer;position:absolute;top:0;right:2px;bottom:0;width:16px;height:16px;margin:auto;line-height:14px;text-align:center;border-radius:8px;border:1px solid #ddd;font-size:9px;background-color:#fff;color:#000}.st-small-ui .st-table td,.st-small-ui .st-table th{height:40px;padding-left:4px;padding-right:4px}.st-small-ui .st-table td svg,.st-small-ui .st-table th svg{margin-top:9px}.st-table{cursor:pointer}.st-table table{position:absolute;border-collapse:collapse;width:100%;border-bottom:1px solid #ddd}.st-table td,.st-table th{position:relative;border-right:1px solid #ddd;padding-left:2px;padding-right:2px;min-width:40px;height:20px}.st-table td,.st-table td>div,.st-table th,.st-table th>div{text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.st-table td:last-child,.st-table th:last-child{border-right:none}.st-table tbody tr{background-color:#fff}.st-table tbody tr:nth-child(even){background-color:#eee;background-color:#f8f4f4}.st-table tbody tr.st-table-item-selected{background-color:#3c68d6;color:#fff}.st-table tbody tr.st-track-list-header{background-color:#fff;border-top:1px solid #ddd}.st-table tbody tr.st-track-list-header:first-child{border-top:none}.st-table tbody tr.st-track-list-header td{padding:10px}.st-table tbody tr.st-track-list-header .st-track-list-header-album{white-space:normal;font-size:2rem}.st-table tbody tr.st-track-list-header .st-track-list-header-artist{white-space:normal}.st-table tbody tr.st-table-group-header-labels{background-color:#fff;border-bottom:1px solid #ddd}.st-table tbody tr.st-table-group-header-labels td{border-right:none}.st-table tbody .st-playing-track-indicator{position:absolute;top:0;right:0;bottom:0}.st-modal-container{position:fixed;background-color:rgba(0,0,0,.3);top:0;right:0;bottom:0;left:0;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-align:center;-webkit-align-items:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-webkit-justify-content:center;-ms-flex-pack:center;justify-content:center;z-index:1}.st-modal-container>*{z-index:2}.st-track-info-modal{width:300px;position:relative}.st-track-info-modal .st-nav-bar{position:relative;height:44px;line-height:44px;text-align:center;background-color:#eee;border-top-left-radius:5px;border-top-right-radius:5px}.st-track-info-modal .st-nav-bar .st-close-button{position:absolute;top:0;left:0;bottom:0;width:44px;line-height:44px;cursor:pointer;font-size:24px}.st-track-info-modal .st-track-info{height:300px;overflow-x:auto;overflow-y:auto}.st-track-info-modal .st-track-info .st-table{cursor:default}body{margin:0;padding:0;font-family:sans-serif}/*! normalize.css v5.0.0 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,footer,header,nav,section{display:block}h1{font-size:2em;margin:.67em 0}figcaption,figure,main{display:block}figure{margin:1em 40px}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent;-webkit-text-decoration-skip:objects}a:active,a:hover{outline-width:0}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:inherit;font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}dfn{font-style:italic}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}audio,video{display:inline-block}audio:not([controls]){display:none;height:0}img{border-style:none}svg:not(:root){overflow:hidden}button,input,optgroup,select,textarea{font-family:sans-serif;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{display:inline-block;vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details,menu{display:block}summary{display:list-item}canvas{display:inline-block}[hidden],template{display:none}
2 | /*# sourceMappingURL=main.3bd60646.css.map*/
--------------------------------------------------------------------------------