├── 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 | 20 | 21 | 22 | {post && } 23 | {renderPlusSign && ( 24 | 25 | 26 | 27 | 28 | )} 29 | 30 | 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 | 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | } 47 | 48 | function uiConfigIconLarge(size=30, color="#666") { 49 | return ( 50 | 51 | 52 | 53 | 54 | 55 | ) 56 | } 57 | 58 | function uiConfigIconMedium(size=30, color="#666") { 59 | return ( 60 | 61 | 62 | 63 | 64 | 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 | openInfoModal(data.item)}> 64 | info 65 | 66 | enqueueTrack(data.item)}> 67 | enqueue 68 | 69 | playTracks(data.playerQueueGetter(data.i))}> 70 | play from here 71 | 72 | 73 | ); 74 | } 75 | 76 | const PlaylistContextMenu = () => { 77 | return ( 78 | 79 | openInfoModal(data.item)}> 80 | info 81 | 82 | removeTrackAtIndex(data.i)}> 83 | remove 84 | 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 | 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 ; 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 |
{name}
{value}
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 | 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*/ --------------------------------------------------------------------------------
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 |