├── tests ├── __init__.py ├── js │ ├── test_custom_scripting.js │ ├── test_library.js │ ├── dummy_tracklist.js │ └── test_controls.js ├── test_extension.py ├── test_webclient.py └── test_web.py ├── .csslintrc ├── setup.py ├── .coveragerc ├── screenshots ├── overview.png ├── album_mobile.png ├── queue_desktop.png ├── search_desktop.png ├── search_mobile.png ├── mobile_overview.png ├── navigation_mobile.png ├── nowplaying_mobile.png └── playlists_desktop.png ├── mopidy_musicbox_webclient ├── ext.conf ├── static │ ├── images │ │ ├── empty.png │ │ ├── loader.gif │ │ ├── user_24x32.png │ │ ├── default_cover.png │ │ └── icons │ │ │ ├── musicbox32.gif │ │ │ ├── musicbox32.png │ │ │ ├── musicbox57.png │ │ │ ├── musicbox72.png │ │ │ ├── musicbox114.png │ │ │ ├── play_alt_12x12.png │ │ │ └── play_alt_16x16.png │ ├── vendors │ │ ├── font_awesome │ │ │ ├── less │ │ │ │ ├── fixed-width.less │ │ │ │ ├── screen-reader.less │ │ │ │ ├── larger.less │ │ │ │ ├── list.less │ │ │ │ ├── core.less │ │ │ │ ├── stacked.less │ │ │ │ ├── font-awesome.less │ │ │ │ ├── bordered-pulled.less │ │ │ │ ├── rotated-flipped.less │ │ │ │ ├── path.less │ │ │ │ ├── animated.less │ │ │ │ └── mixins.less │ │ │ ├── fonts │ │ │ │ ├── FontAwesome.otf │ │ │ │ ├── fontawesome-webfont.eot │ │ │ │ ├── fontawesome-webfont.ttf │ │ │ │ ├── fontawesome-webfont.woff │ │ │ │ └── fontawesome-webfont.woff2 │ │ │ └── scss │ │ │ │ ├── _fixed-width.scss │ │ │ │ ├── _screen-reader.scss │ │ │ │ ├── _larger.scss │ │ │ │ ├── _list.scss │ │ │ │ ├── _core.scss │ │ │ │ ├── font-awesome.scss │ │ │ │ ├── _stacked.scss │ │ │ │ ├── _bordered-pulled.scss │ │ │ │ ├── _rotated-flipped.scss │ │ │ │ ├── _path.scss │ │ │ │ ├── _animated.scss │ │ │ │ └── _mixins.scss │ │ ├── jquery_mobile │ │ │ ├── images │ │ │ │ ├── ajax-loader.gif │ │ │ │ ├── icons-18-black.png │ │ │ │ ├── icons-18-white.png │ │ │ │ ├── icons-36-black.png │ │ │ │ └── icons-36-white.png │ │ │ └── index.html │ │ ├── jquery_mobile_flat_ui_theme │ │ │ ├── fonts │ │ │ │ ├── lato-bold.ttf │ │ │ │ ├── lato-black.ttf │ │ │ │ ├── lato-black.woff │ │ │ │ ├── lato-bold.woff │ │ │ │ ├── lato-italic.ttf │ │ │ │ ├── lato-italic.woff │ │ │ │ ├── lato-regular.ttf │ │ │ │ ├── lato-regular.woff │ │ │ │ ├── Flat-UI-Icons-24.ttf │ │ │ │ └── Flat-UI-Icons-24.woff │ │ │ └── images │ │ │ │ ├── ajax-loader.gif │ │ │ │ ├── icons-18-black.png │ │ │ │ ├── icons-18-white.png │ │ │ │ ├── icons-36-black.png │ │ │ │ └── icons-36-white.png │ │ ├── lastfm │ │ │ ├── lastfm.api.cache.js │ │ │ └── lastfm.api.md5.js │ │ ├── jquery_cookie │ │ │ └── jquery.cookie.js │ │ └── media_progress_timer │ │ │ └── timer.js │ ├── dialog-success.html │ ├── plugins │ │ ├── keysocket.js │ │ └── plugin-loader.js │ ├── js │ │ ├── custom_scripting.js │ │ ├── synced_timer.js │ │ ├── process_ws.js │ │ ├── images.js │ │ └── library.js │ ├── system.html │ ├── mb.appcache │ └── css │ │ └── webclient.css ├── webclient.py ├── __init__.py └── web.py ├── .gitignore ├── AUTHORS ├── pyproject.toml ├── .eslintrc ├── MANIFEST.in ├── .circleci └── config.yml ├── tox.ini ├── package.json ├── setup.cfg ├── tidy.js ├── karma.conf.js ├── README.rst ├── LICENSE └── CHANGELOG.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.csslintrc: -------------------------------------------------------------------------------- 1 | --format=compact 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = 3 | */pyshared/* 4 | */python?.?/* 5 | */site-packages/nose/* 6 | -------------------------------------------------------------------------------- /screenshots/overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/screenshots/overview.png -------------------------------------------------------------------------------- /screenshots/album_mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/screenshots/album_mobile.png -------------------------------------------------------------------------------- /screenshots/queue_desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/screenshots/queue_desktop.png -------------------------------------------------------------------------------- /screenshots/search_desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/screenshots/search_desktop.png -------------------------------------------------------------------------------- /screenshots/search_mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/screenshots/search_mobile.png -------------------------------------------------------------------------------- /screenshots/mobile_overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/screenshots/mobile_overview.png -------------------------------------------------------------------------------- /screenshots/navigation_mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/screenshots/navigation_mobile.png -------------------------------------------------------------------------------- /screenshots/nowplaying_mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/screenshots/nowplaying_mobile.png -------------------------------------------------------------------------------- /screenshots/playlists_desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/screenshots/playlists_desktop.png -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/ext.conf: -------------------------------------------------------------------------------- 1 | [musicbox_webclient] 2 | enabled = true 3 | musicbox = false 4 | websocket_host = 5 | websocket_port = 6 | on_track_click = PLAY_ALL 7 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/images/empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/images/empty.png -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/images/loader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/images/loader.gif -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/images/user_24x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/images/user_24x32.png -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/images/default_cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/images/default_cover.png -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/images/icons/musicbox32.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/images/icons/musicbox32.gif -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/images/icons/musicbox32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/images/icons/musicbox32.png -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/images/icons/musicbox57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/images/icons/musicbox57.png -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/images/icons/musicbox72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/images/icons/musicbox72.png -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/images/icons/musicbox114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/images/icons/musicbox114.png -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/images/icons/play_alt_12x12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/images/icons/play_alt_12x12.png -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/images/icons/play_alt_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/images/icons/play_alt_16x16.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | /.coverage 3 | /.mypy_cache/ 4 | /.pytest_cache/ 5 | /.tox/ 6 | /*.egg-info 7 | /build/ 8 | /dist/ 9 | /MANIFEST 10 | /.karma_coverage 11 | /node_modules 12 | package-lock.json 13 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/less/fixed-width.less: -------------------------------------------------------------------------------- 1 | // Fixed Width Icons 2 | // ------------------------- 3 | .@{fa-css-prefix}-fw { 4 | width: (18em / 14); 5 | text-align: center; 6 | } 7 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/less/screen-reader.less: -------------------------------------------------------------------------------- 1 | // Screen Readers 2 | // ------------------------- 3 | 4 | .sr-only { .sr-only(); } 5 | .sr-only-focusable { .sr-only-focusable(); } 6 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/fonts/FontAwesome.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/font_awesome/fonts/FontAwesome.otf -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/scss/_fixed-width.scss: -------------------------------------------------------------------------------- 1 | // Fixed Width Icons 2 | // ------------------------- 3 | .#{$fa-css-prefix}-fw { 4 | width: (18em / 14); 5 | text-align: center; 6 | } 7 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_mobile/images/ajax-loader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/jquery_mobile/images/ajax-loader.gif -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/scss/_screen-reader.scss: -------------------------------------------------------------------------------- 1 | // Screen Readers 2 | // ------------------------- 3 | 4 | .sr-only { @include sr-only(); } 5 | .sr-only-focusable { @include sr-only-focusable(); } 6 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_mobile/images/icons-18-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/jquery_mobile/images/icons-18-black.png -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_mobile/images/icons-18-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/jquery_mobile/images/icons-18-white.png -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_mobile/images/icons-36-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/jquery_mobile/images/icons-36-black.png -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_mobile/images/icons-36-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/jquery_mobile/images/icons-36-white.png -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/fonts/fontawesome-webfont.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/font_awesome/fonts/fontawesome-webfont.eot -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/fonts/fontawesome-webfont.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/font_awesome/fonts/fontawesome-webfont.ttf -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/fonts/fontawesome-webfont.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/font_awesome/fonts/fontawesome-webfont.woff -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/fonts/fontawesome-webfont.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/font_awesome/fonts/fontawesome-webfont.woff2 -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/fonts/lato-bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/fonts/lato-bold.ttf -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/fonts/lato-black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/fonts/lato-black.ttf -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/fonts/lato-black.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/fonts/lato-black.woff -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/fonts/lato-bold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/fonts/lato-bold.woff -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/fonts/lato-italic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/fonts/lato-italic.ttf -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/fonts/lato-italic.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/fonts/lato-italic.woff -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/fonts/lato-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/fonts/lato-regular.ttf -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/fonts/lato-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/fonts/lato-regular.woff -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/images/ajax-loader.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/images/ajax-loader.gif -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/fonts/Flat-UI-Icons-24.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/fonts/Flat-UI-Icons-24.ttf -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/images/icons-18-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/images/icons-18-black.png -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/images/icons-18-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/images/icons-18-white.png -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/images/icons-36-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/images/icons-36-black.png -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/images/icons-36-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/images/icons-36-white.png -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/fonts/Flat-UI-Icons-24.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pimusicbox/mopidy-musicbox-webclient/HEAD/mopidy_musicbox_webclient/static/vendors/jquery_mobile_flat_ui_theme/fonts/Flat-UI-Icons-24.woff -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Webclient 2 | - Wouter van Wijk 3 | - Flat Interface: Ulrich Lichtenegger 4 | - Nick Steel 5 | - Szymon Nowak 6 | - John Cass 7 | - André Gaul 8 | - Dāvis Mošenkovs 9 | - Bruce Tsai 10 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools >= 30.3.0", "wheel"] 3 | 4 | 5 | [tool.black] 6 | target-version = ["py37", "py38"] 7 | line-length = 80 8 | 9 | 10 | [tool.isort] 11 | multi_line_output = 3 12 | include_trailing_comma = true 13 | force_grid_wrap = 0 14 | use_parentheses = true 15 | line_length = 88 16 | known_tests = "tests" 17 | sections = "FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,TESTS,LOCALFOLDER" 18 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/less/larger.less: -------------------------------------------------------------------------------- 1 | // Icon Sizes 2 | // ------------------------- 3 | 4 | /* makes the font 33% larger relative to the icon container */ 5 | .@{fa-css-prefix}-lg { 6 | font-size: (4em / 3); 7 | line-height: (3em / 4); 8 | vertical-align: -15%; 9 | } 10 | .@{fa-css-prefix}-2x { font-size: 2em; } 11 | .@{fa-css-prefix}-3x { font-size: 3em; } 12 | .@{fa-css-prefix}-4x { font-size: 4em; } 13 | .@{fa-css-prefix}-5x { font-size: 5em; } 14 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/scss/_larger.scss: -------------------------------------------------------------------------------- 1 | // Icon Sizes 2 | // ------------------------- 3 | 4 | /* makes the font 33% larger relative to the icon container */ 5 | .#{$fa-css-prefix}-lg { 6 | font-size: (4em / 3); 7 | line-height: (3em / 4); 8 | vertical-align: -15%; 9 | } 10 | .#{$fa-css-prefix}-2x { font-size: 2em; } 11 | .#{$fa-css-prefix}-3x { font-size: 3em; } 12 | .#{$fa-css-prefix}-4x { font-size: 4em; } 13 | .#{$fa-css-prefix}-5x { font-size: 5em; } 14 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "standard", 3 | "env": { 4 | "jquery": true 5 | }, 6 | "rules": { 7 | "indent": [2, 4, {"SwitchCase": 1}], 8 | "no-undef": 0, // TODO: Set this to '2' once Javascript has been modularised. 9 | "no-unused-vars": 0, // TODO: Set this to '2' once Javascript has been modularised. 10 | "camelcase": 0, 11 | "no-multi-spaces": ["error", {"ignoreEOLComments": true}], 12 | "no-useless-escape": 0, 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/less/list.less: -------------------------------------------------------------------------------- 1 | // List Icons 2 | // ------------------------- 3 | 4 | .@{fa-css-prefix}-ul { 5 | padding-left: 0; 6 | margin-left: @fa-li-width; 7 | list-style-type: none; 8 | > li { position: relative; } 9 | } 10 | .@{fa-css-prefix}-li { 11 | position: absolute; 12 | left: -@fa-li-width; 13 | width: @fa-li-width; 14 | top: (2em / 14); 15 | text-align: center; 16 | &.@{fa-css-prefix}-lg { 17 | left: (-@fa-li-width + (4em / 14)); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/scss/_list.scss: -------------------------------------------------------------------------------- 1 | // List Icons 2 | // ------------------------- 3 | 4 | .#{$fa-css-prefix}-ul { 5 | padding-left: 0; 6 | margin-left: $fa-li-width; 7 | list-style-type: none; 8 | > li { position: relative; } 9 | } 10 | .#{$fa-css-prefix}-li { 11 | position: absolute; 12 | left: -$fa-li-width; 13 | width: $fa-li-width; 14 | top: (2em / 14); 15 | text-align: center; 16 | &.#{$fa-css-prefix}-lg { 17 | left: -$fa-li-width + (4em / 14); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.py 2 | include *.rst 3 | include .mailmap 4 | include AUTHORS 5 | include LICENSE 6 | include MANIFEST.in 7 | include pyproject.toml 8 | include tox.ini 9 | 10 | recursive-include .circleci * 11 | recursive-include .github * 12 | 13 | include mopidy_*/ext.conf 14 | 15 | recursive-include tests *.py *.js 16 | recursive-include tests/data * 17 | 18 | include *.js 19 | include .coveragerc 20 | include .csslintrc 21 | include .eslintrc 22 | include package.json 23 | 24 | recursive-include mopidy_musicbox_webclient/static * 25 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/less/core.less: -------------------------------------------------------------------------------- 1 | // Base Class Definition 2 | // ------------------------- 3 | 4 | .@{fa-css-prefix} { 5 | display: inline-block; 6 | font: normal normal normal @fa-font-size-base/@fa-line-height-base FontAwesome; // shortening font declaration 7 | font-size: inherit; // can't have font-size inherit on line above, so need to override 8 | text-rendering: auto; // optimizelegibility throws things off #1094 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | 12 | } 13 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/scss/_core.scss: -------------------------------------------------------------------------------- 1 | // Base Class Definition 2 | // ------------------------- 3 | 4 | .#{$fa-css-prefix} { 5 | display: inline-block; 6 | font: normal normal normal #{$fa-font-size-base}/#{$fa-line-height-base} FontAwesome; // shortening font declaration 7 | font-size: inherit; // can't have font-size inherit on line above, so need to override 8 | text-rendering: auto; // optimizelegibility throws things off #1094 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | 12 | } 13 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/scss/font-awesome.scss: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome 3 | * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) 4 | */ 5 | 6 | @import "variables"; 7 | @import "mixins"; 8 | @import "path"; 9 | @import "core"; 10 | @import "larger"; 11 | @import "fixed-width"; 12 | @import "list"; 13 | @import "bordered-pulled"; 14 | @import "animated"; 15 | @import "rotated-flipped"; 16 | @import "stacked"; 17 | @import "icons"; 18 | @import "screen-reader"; 19 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/less/stacked.less: -------------------------------------------------------------------------------- 1 | // Stacked Icons 2 | // ------------------------- 3 | 4 | .@{fa-css-prefix}-stack { 5 | position: relative; 6 | display: inline-block; 7 | width: 2em; 8 | height: 2em; 9 | line-height: 2em; 10 | vertical-align: middle; 11 | } 12 | .@{fa-css-prefix}-stack-1x, .@{fa-css-prefix}-stack-2x { 13 | position: absolute; 14 | left: 0; 15 | width: 100%; 16 | text-align: center; 17 | } 18 | .@{fa-css-prefix}-stack-1x { line-height: inherit; } 19 | .@{fa-css-prefix}-stack-2x { font-size: 2em; } 20 | .@{fa-css-prefix}-inverse { color: @fa-inverse; } 21 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/scss/_stacked.scss: -------------------------------------------------------------------------------- 1 | // Stacked Icons 2 | // ------------------------- 3 | 4 | .#{$fa-css-prefix}-stack { 5 | position: relative; 6 | display: inline-block; 7 | width: 2em; 8 | height: 2em; 9 | line-height: 2em; 10 | vertical-align: middle; 11 | } 12 | .#{$fa-css-prefix}-stack-1x, .#{$fa-css-prefix}-stack-2x { 13 | position: absolute; 14 | left: 0; 15 | width: 100%; 16 | text-align: center; 17 | } 18 | .#{$fa-css-prefix}-stack-1x { line-height: inherit; } 19 | .#{$fa-css-prefix}-stack-2x { font-size: 2em; } 20 | .#{$fa-css-prefix}-inverse { color: $fa-inverse; } 21 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/less/font-awesome.less: -------------------------------------------------------------------------------- 1 | /*! 2 | * Font Awesome 4.7.0 by @davegandy - http://fontawesome.io - @fontawesome 3 | * License - http://fontawesome.io/license (Font: SIL OFL 1.1, CSS: MIT License) 4 | */ 5 | 6 | @import "variables.less"; 7 | @import "mixins.less"; 8 | @import "path.less"; 9 | @import "core.less"; 10 | @import "larger.less"; 11 | @import "fixed-width.less"; 12 | @import "list.less"; 13 | @import "bordered-pulled.less"; 14 | @import "animated.less"; 15 | @import "rotated-flipped.less"; 16 | @import "stacked.less"; 17 | @import "icons.less"; 18 | @import "screen-reader.less"; 19 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/dialog-success.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Success dialog 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 |

Initiating shutdown/reboot...

16 |
17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/plugins/keysocket.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Plugin to support the Key Socket Media Keys chrome plugin. The plugin allows for controlling the music using the media keys. 3 | * https://github.com/borismus/keysocket 4 | * https://smus.com/chrome-media-keys-revisited/ 5 | * https://chrome.google.com/webstore/detail/key-socket-media-keys/fphfgdknbpakeedbaenojjdcdoajihik?hl=en 6 | */ 7 | 8 | document.addEventListener("MediaPlayPause", function () { 9 | controls.doPlay(); 10 | }); 11 | 12 | document.addEventListener("MediaPrev", function () { 13 | controls.doPrevious(); 14 | }); 15 | 16 | document.addEventListener("MediaNext", function () { 17 | controls.doNext(); 18 | }); 19 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/less/bordered-pulled.less: -------------------------------------------------------------------------------- 1 | // Bordered & Pulled 2 | // ------------------------- 3 | 4 | .@{fa-css-prefix}-border { 5 | padding: .2em .25em .15em; 6 | border: solid .08em @fa-border-color; 7 | border-radius: .1em; 8 | } 9 | 10 | .@{fa-css-prefix}-pull-left { float: left; } 11 | .@{fa-css-prefix}-pull-right { float: right; } 12 | 13 | .@{fa-css-prefix} { 14 | &.@{fa-css-prefix}-pull-left { margin-right: .3em; } 15 | &.@{fa-css-prefix}-pull-right { margin-left: .3em; } 16 | } 17 | 18 | /* Deprecated as of 4.4.0 */ 19 | .pull-right { float: right; } 20 | .pull-left { float: left; } 21 | 22 | .@{fa-css-prefix} { 23 | &.pull-left { margin-right: .3em; } 24 | &.pull-right { margin-left: .3em; } 25 | } 26 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/scss/_bordered-pulled.scss: -------------------------------------------------------------------------------- 1 | // Bordered & Pulled 2 | // ------------------------- 3 | 4 | .#{$fa-css-prefix}-border { 5 | padding: .2em .25em .15em; 6 | border: solid .08em $fa-border-color; 7 | border-radius: .1em; 8 | } 9 | 10 | .#{$fa-css-prefix}-pull-left { float: left; } 11 | .#{$fa-css-prefix}-pull-right { float: right; } 12 | 13 | .#{$fa-css-prefix} { 14 | &.#{$fa-css-prefix}-pull-left { margin-right: .3em; } 15 | &.#{$fa-css-prefix}-pull-right { margin-left: .3em; } 16 | } 17 | 18 | /* Deprecated as of 4.4.0 */ 19 | .pull-right { float: right; } 20 | .pull-left { float: left; } 21 | 22 | .#{$fa-css-prefix} { 23 | &.pull-left { margin-right: .3em; } 24 | &.pull-right { margin-left: .3em; } 25 | } 26 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/less/rotated-flipped.less: -------------------------------------------------------------------------------- 1 | // Rotated & Flipped Icons 2 | // ------------------------- 3 | 4 | .@{fa-css-prefix}-rotate-90 { .fa-icon-rotate(90deg, 1); } 5 | .@{fa-css-prefix}-rotate-180 { .fa-icon-rotate(180deg, 2); } 6 | .@{fa-css-prefix}-rotate-270 { .fa-icon-rotate(270deg, 3); } 7 | 8 | .@{fa-css-prefix}-flip-horizontal { .fa-icon-flip(-1, 1, 0); } 9 | .@{fa-css-prefix}-flip-vertical { .fa-icon-flip(1, -1, 2); } 10 | 11 | // Hook for IE8-9 12 | // ------------------------- 13 | 14 | :root .@{fa-css-prefix}-rotate-90, 15 | :root .@{fa-css-prefix}-rotate-180, 16 | :root .@{fa-css-prefix}-rotate-270, 17 | :root .@{fa-css-prefix}-flip-horizontal, 18 | :root .@{fa-css-prefix}-flip-vertical { 19 | filter: none; 20 | } 21 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/scss/_rotated-flipped.scss: -------------------------------------------------------------------------------- 1 | // Rotated & Flipped Icons 2 | // ------------------------- 3 | 4 | .#{$fa-css-prefix}-rotate-90 { @include fa-icon-rotate(90deg, 1); } 5 | .#{$fa-css-prefix}-rotate-180 { @include fa-icon-rotate(180deg, 2); } 6 | .#{$fa-css-prefix}-rotate-270 { @include fa-icon-rotate(270deg, 3); } 7 | 8 | .#{$fa-css-prefix}-flip-horizontal { @include fa-icon-flip(-1, 1, 0); } 9 | .#{$fa-css-prefix}-flip-vertical { @include fa-icon-flip(1, -1, 2); } 10 | 11 | // Hook for IE8-9 12 | // ------------------------- 13 | 14 | :root .#{$fa-css-prefix}-rotate-90, 15 | :root .#{$fa-css-prefix}-rotate-180, 16 | :root .#{$fa-css-prefix}-rotate-270, 17 | :root .#{$fa-css-prefix}-flip-horizontal, 18 | :root .#{$fa-css-prefix}-flip-vertical { 19 | filter: none; 20 | } 21 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/plugins/plugin-loader.js: -------------------------------------------------------------------------------- 1 | var enabledPlugins = [ 2 | 'keysocket' 3 | ]; 4 | 5 | 6 | function scriptLoader(path, callback) { 7 | var script = document.createElement('script'); 8 | script.type = "text/javascript"; 9 | script.async = true; 10 | script.src = path; 11 | script.onload = function () { 12 | if (typeof (callback) == "function") { 13 | callback(); 14 | } 15 | }; 16 | try { 17 | var scriptOne = document.getElementsByTagName('script')[0]; 18 | scriptOne.parentNode.insertBefore(script, scriptOne); 19 | } catch (e) { 20 | document.getElementsByTagName("head")[0].appendChild(script); 21 | } 22 | } 23 | 24 | for (var i in enabledPlugins) { 25 | var plugin = enabledPlugins[i]; 26 | var path = `plugins/${plugin}.js`; 27 | scriptLoader(path); 28 | } 29 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/less/path.less: -------------------------------------------------------------------------------- 1 | /* FONT PATH 2 | * -------------------------- */ 3 | 4 | @font-face { 5 | font-family: 'FontAwesome'; 6 | src: url('@{fa-font-path}/fontawesome-webfont.eot?v=@{fa-version}'); 7 | src: url('@{fa-font-path}/fontawesome-webfont.eot?#iefix&v=@{fa-version}') format('embedded-opentype'), 8 | url('@{fa-font-path}/fontawesome-webfont.woff2?v=@{fa-version}') format('woff2'), 9 | url('@{fa-font-path}/fontawesome-webfont.woff?v=@{fa-version}') format('woff'), 10 | url('@{fa-font-path}/fontawesome-webfont.ttf?v=@{fa-version}') format('truetype'), 11 | url('@{fa-font-path}/fontawesome-webfont.svg?v=@{fa-version}#fontawesomeregular') format('svg'); 12 | // src: url('@{fa-font-path}/FontAwesome.otf') format('opentype'); // used when developing fonts 13 | font-weight: normal; 14 | font-style: normal; 15 | } 16 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/scss/_path.scss: -------------------------------------------------------------------------------- 1 | /* FONT PATH 2 | * -------------------------- */ 3 | 4 | @font-face { 5 | font-family: 'FontAwesome'; 6 | src: url('#{$fa-font-path}/fontawesome-webfont.eot?v=#{$fa-version}'); 7 | src: url('#{$fa-font-path}/fontawesome-webfont.eot?#iefix&v=#{$fa-version}') format('embedded-opentype'), 8 | url('#{$fa-font-path}/fontawesome-webfont.woff2?v=#{$fa-version}') format('woff2'), 9 | url('#{$fa-font-path}/fontawesome-webfont.woff?v=#{$fa-version}') format('woff'), 10 | url('#{$fa-font-path}/fontawesome-webfont.ttf?v=#{$fa-version}') format('truetype'), 11 | url('#{$fa-font-path}/fontawesome-webfont.svg?v=#{$fa-version}#fontawesomeregular') format('svg'); 12 | // src: url('#{$fa-font-path}/FontAwesome.otf') format('opentype'); // used when developing fonts 13 | font-weight: normal; 14 | font-style: normal; 15 | } 16 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/less/animated.less: -------------------------------------------------------------------------------- 1 | // Animated Icons 2 | // -------------------------- 3 | 4 | .@{fa-css-prefix}-spin { 5 | -webkit-animation: fa-spin 2s infinite linear; 6 | animation: fa-spin 2s infinite linear; 7 | } 8 | 9 | .@{fa-css-prefix}-pulse { 10 | -webkit-animation: fa-spin 1s infinite steps(8); 11 | animation: fa-spin 1s infinite steps(8); 12 | } 13 | 14 | @-webkit-keyframes fa-spin { 15 | 0% { 16 | -webkit-transform: rotate(0deg); 17 | transform: rotate(0deg); 18 | } 19 | 100% { 20 | -webkit-transform: rotate(359deg); 21 | transform: rotate(359deg); 22 | } 23 | } 24 | 25 | @keyframes fa-spin { 26 | 0% { 27 | -webkit-transform: rotate(0deg); 28 | transform: rotate(0deg); 29 | } 30 | 100% { 31 | -webkit-transform: rotate(359deg); 32 | transform: rotate(359deg); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/scss/_animated.scss: -------------------------------------------------------------------------------- 1 | // Spinning Icons 2 | // -------------------------- 3 | 4 | .#{$fa-css-prefix}-spin { 5 | -webkit-animation: fa-spin 2s infinite linear; 6 | animation: fa-spin 2s infinite linear; 7 | } 8 | 9 | .#{$fa-css-prefix}-pulse { 10 | -webkit-animation: fa-spin 1s infinite steps(8); 11 | animation: fa-spin 1s infinite steps(8); 12 | } 13 | 14 | @-webkit-keyframes fa-spin { 15 | 0% { 16 | -webkit-transform: rotate(0deg); 17 | transform: rotate(0deg); 18 | } 19 | 100% { 20 | -webkit-transform: rotate(359deg); 21 | transform: rotate(359deg); 22 | } 23 | } 24 | 25 | @keyframes fa-spin { 26 | 0% { 27 | -webkit-transform: rotate(0deg); 28 | transform: rotate(0deg); 29 | } 30 | 100% { 31 | -webkit-transform: rotate(359deg); 32 | transform: rotate(359deg); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /tests/js/test_custom_scripting.js: -------------------------------------------------------------------------------- 1 | var chai = require('chai') 2 | var expect = chai.expect 3 | var assert = chai.assert 4 | chai.use(require('chai-string')) 5 | chai.use(require('chai-jquery')) 6 | 7 | var sinon = require('sinon') 8 | 9 | var configureJQueryMobile = require('../../mopidy_musicbox_webclient/static/js/custom_scripting.js') 10 | 11 | describe('jQuery Defaults', function () { 12 | it('should disable ajax and hashListening', function () { 13 | expect($.mobile.ajaxEnabled).to.be.true 14 | expect($.mobile.hashListeningEnabled).to.be.true 15 | 16 | configureJQueryMobile() 17 | expect($.mobile.ajaxEnabled).to.be.false 18 | expect($.mobile.hashListeningEnabled).to.be.false 19 | }) 20 | 21 | it('should bind to "mobileinit"', function () { 22 | var configSpy = sinon.spy(configureJQueryMobile) 23 | 24 | $(document).bind('mobileinit', configSpy) 25 | expect(configSpy.called).to.be.false 26 | $(document).trigger('mobileinit') 27 | expect(configSpy.called).to.be.true 28 | configSpy.reset() 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /tests/test_extension.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest import mock 3 | 4 | from mopidy_musicbox_webclient import Extension 5 | 6 | 7 | class ExtensionTests(unittest.TestCase): 8 | def test_get_default_config(self): 9 | ext = Extension() 10 | 11 | config = ext.get_default_config() 12 | 13 | assert "[musicbox_webclient]" in config 14 | assert "enabled = true" in config 15 | assert "websocket_host =" in config 16 | assert "websocket_port =" in config 17 | assert "on_track_click = PLAY_ALL" in config 18 | 19 | def test_get_config_schema(self): 20 | ext = Extension() 21 | 22 | schema = ext.get_config_schema() 23 | 24 | assert "musicbox" in schema 25 | assert "websocket_host" in schema 26 | assert "websocket_port" in schema 27 | assert "on_track_click" in schema 28 | 29 | def test_setup(self): 30 | registry = mock.Mock() 31 | 32 | ext = Extension() 33 | ext.setup(registry) 34 | calls = [ 35 | mock.call( 36 | "http:app", {"name": ext.ext_name, "factory": ext.factory} 37 | ) 38 | ] 39 | registry.add.assert_has_calls(calls, any_order=True) 40 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/js/custom_scripting.js: -------------------------------------------------------------------------------- 1 | // jQuery Mobile configuration options 2 | // see: http://api.jquerymobile.com/1.3/global-config/ 3 | (function (root, factory) { 4 | if (typeof define === 'function' && define.amd) { 5 | define([], factory) 6 | } else if (typeof module === 'object' && module.exports) { 7 | module.exports = factory() 8 | } else { 9 | root.configureJQueryMobile = factory() 10 | } 11 | }(this, function () { 12 | 'use strict' 13 | 14 | function configureJQueryMobile () { 15 | $.extend($.mobile, { 16 | ajaxEnabled: false, 17 | hashListeningEnabled: false 18 | }) 19 | } 20 | 21 | $(document).bind('mobileinit', configureJQueryMobile) 22 | 23 | // Extension: timeout to detect end of scrolling action. 24 | $.fn.scrollEnd = function (callback, timeout) { 25 | $(this).scroll(function () { 26 | var $this = $(this) 27 | if ($this.data('scrollTimeout')) { 28 | clearTimeout($this.data('scrollTimeout')) 29 | } 30 | $this.data('scrollTimeout', setTimeout(callback, timeout)) 31 | }) 32 | } 33 | 34 | return configureJQueryMobile 35 | })) 36 | 37 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_mobile/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | jQuery Mobile 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 24 | 25 | 26 |
27 | 28 |
29 | 30 |

Nothing to see here folks. View the demo center home page →

31 | 32 |
33 | 34 |
35 | 36 | 37 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | 3 | orbs: 4 | codecov: codecov/codecov@1.0.5 5 | 6 | workflows: 7 | version: 2 8 | test: 9 | jobs: 10 | - py38 11 | - py37 12 | - black 13 | - check-manifest 14 | - flake8 15 | #- jstest 16 | - eslint 17 | - csslint 18 | - jstidy 19 | 20 | jobs: 21 | py38: &test-template 22 | docker: 23 | - image: mopidy/ci-python:3.8 24 | steps: 25 | - checkout 26 | - restore_cache: 27 | name: Restoring tox cache 28 | key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }} 29 | - run: 30 | name: Run tests 31 | command: | 32 | tox -e $CIRCLE_JOB -- \ 33 | --junit-xml=test-results/pytest/results.xml \ 34 | --cov-report=xml 35 | - save_cache: 36 | name: Saving tox cache 37 | key: tox-v1-{{ .Environment.CIRCLE_JOB }}-{{ checksum "setup.cfg" }} 38 | paths: 39 | - ./.tox 40 | - ~/.cache/pip 41 | - codecov/upload: 42 | file: coverage.xml 43 | - store_test_results: 44 | path: test-results 45 | 46 | py37: 47 | <<: *test-template 48 | docker: 49 | - image: mopidy/ci-python:3.7 50 | 51 | black: *test-template 52 | 53 | check-manifest: *test-template 54 | 55 | flake8: *test-template 56 | 57 | jstest: *test-template 58 | 59 | eslint: *test-template 60 | 61 | csslint: *test-template 62 | 63 | jstidy: *test-template 64 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/system.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | System 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 |
25 |

System

26 | Shutdown 27 | Reboot 28 | Cancel 29 |
30 |
31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37, py38, black, check-manifest, flake8, eslint, csslint, jstidy 3 | #envlist = py37, py38, black, check-manifest, flake8, jstest, eslint, csslint, jstidy 4 | 5 | [testenv] 6 | sitepackages = true 7 | deps = .[test] 8 | commands = 9 | python -m pytest \ 10 | --basetemp={envtmpdir} \ 11 | --cov=mopidy_musicbox_webclient --cov-report=term-missing \ 12 | {posargs} 13 | 14 | [testenv:black] 15 | deps = .[lint] 16 | commands = python -m black --check . 17 | 18 | [testenv:check-manifest] 19 | deps = .[lint] 20 | commands = python -m check_manifest 21 | 22 | [testenv:flake8] 23 | deps = .[lint] 24 | commands = python -m flake8 --show-source --statistics --max-line-length 120 25 | 26 | [testenv:jstest] 27 | whitelist_externals = 28 | /bin/bash 29 | deps = .[node] 30 | commands = 31 | - nodeenv --prebuilt {toxworkdir}/node_env 32 | bash -c '. {toxworkdir}/node_env/bin/activate; npm install; npm test' 33 | 34 | [testenv:eslint] 35 | whitelist_externals = 36 | /bin/bash 37 | deps = .[node] 38 | commands = 39 | - nodeenv --prebuilt {toxworkdir}/node_env 40 | bash -c '. {toxworkdir}/node_env/bin/activate; npm install; npm run eslint' 41 | 42 | [testenv:csslint] 43 | whitelist_externals = 44 | /bin/bash 45 | deps = .[node] 46 | skip_install = true 47 | commands = 48 | - nodeenv --prebuilt {toxworkdir}/node_env 49 | bash -c '. {toxworkdir}/node_env/bin/activate; npm install; npm run csslint' 50 | 51 | [testenv:jstidy] 52 | whitelist_externals = 53 | /bin/bash 54 | deps = .[node] 55 | commands = 56 | - nodeenv --prebuilt {toxworkdir}/node_env 57 | bash -c '. {toxworkdir}/node_env/bin/activate; npm install; npm run tidy' 58 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/webclient.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from mopidy_musicbox_webclient import Extension 4 | 5 | logger = logging.getLogger(__name__) 6 | 7 | 8 | class Webclient: 9 | def __init__(self, config): 10 | self.config = config 11 | 12 | @property 13 | def ext_config(self): 14 | return self.config.get(Extension.ext_name, {}) 15 | 16 | @classmethod 17 | def get_version(cls): 18 | return Extension.version 19 | 20 | def get_websocket_url(self, request): 21 | host, port = ( 22 | self.ext_config["websocket_host"], 23 | self.ext_config["websocket_port"], 24 | ) 25 | ws_url = "" 26 | if host or port: 27 | if not host: 28 | host = request.host.partition(":")[0] 29 | logger.warning( 30 | "Mopidy websocket_host not specified, " "using %s", host 31 | ) 32 | elif not port: 33 | port = self.config["http"]["port"] 34 | logger.warning( 35 | "Mopidy websocket_port not specified, " "using %s", port 36 | ) 37 | protocol = "ws" 38 | if request.protocol == "https": 39 | protocol = "wss" 40 | ws_url = "%s://%s:%d/mopidy/ws" % (protocol, host, port) 41 | 42 | return ws_url 43 | 44 | def has_alarm_clock(self): 45 | return self.config.get("alarmclock", {}).get("enabled", False) 46 | 47 | def is_music_box(self): 48 | return self.ext_config.get("musicbox", False) 49 | 50 | def get_default_click_action(self): 51 | return self.ext_config.get("on_track_click", "PLAY_ALL") 52 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/__init__.py: -------------------------------------------------------------------------------- 1 | import pathlib 2 | 3 | import pkg_resources 4 | 5 | from mopidy import config, ext 6 | 7 | __version__ = pkg_resources.get_distribution( 8 | "Mopidy-MusicBox-Webclient" 9 | ).version 10 | 11 | 12 | class Extension(ext.Extension): 13 | 14 | dist_name = "Mopidy-MusicBox-Webclient" 15 | ext_name = "musicbox_webclient" 16 | version = __version__ 17 | 18 | def get_default_config(self): 19 | return config.read(pathlib.Path(__file__).parent / "ext.conf") 20 | 21 | def get_config_schema(self): 22 | schema = super().get_config_schema() 23 | schema["musicbox"] = config.Boolean(optional=True) 24 | schema["websocket_host"] = config.Hostname(optional=True) 25 | schema["websocket_port"] = config.Port(optional=True) 26 | schema["on_track_click"] = config.String( 27 | optional=True, 28 | choices=[ 29 | "PLAY_NOW", 30 | "PLAY_NEXT", 31 | "ADD_THIS_BOTTOM", 32 | "ADD_ALL_BOTTOM", 33 | "PLAY_ALL", 34 | "DYNAMIC", 35 | ], 36 | ) 37 | return schema 38 | 39 | def setup(self, registry): 40 | registry.add( 41 | "http:app", {"name": self.ext_name, "factory": self.factory} 42 | ) 43 | 44 | def factory(self, config, core): 45 | from tornado.web import RedirectHandler 46 | from .web import IndexHandler, StaticHandler 47 | 48 | path = pathlib.Path(__file__).parent / "static" 49 | return [ 50 | (r"/", RedirectHandler, {"url": "index.html"}), 51 | (r"/(index.html)", IndexHandler, {"config": config, "path": path}), 52 | (r"/(.*)", StaticHandler, {"path": path}), 53 | ] 54 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Mopidy-MusicBox-Webclient", 3 | "version": "2.1.1", 4 | "description": "Mopidy MusicBox web extension", 5 | "main": "gui.js", 6 | "directories": { 7 | "test": "tests" 8 | }, 9 | "scripts": { 10 | "test": "karma start karma.conf.js", 11 | "eslint": "eslint mopidy_musicbox_webclient/static/js/**/**.js tests/**/test_*.js", 12 | "csslint": "csslint mopidy_musicbox_webclient/static/css/**.css", 13 | "tidy": "node tidy.js" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/pimusicbox/mopidy-musicbox-webclient.git" 18 | }, 19 | "author": "Wouter van Wijk", 20 | "license": "Apache-2.0", 21 | "bugs": { 22 | "url": "https://github.com/pimusicbox/mopidy-musicbox-webclient/issues" 23 | }, 24 | "devDependencies": { 25 | "babelify": "^7.2.0", 26 | "browserify": "^13.0.0", 27 | "browserify-istanbul": "^2.0.0", 28 | "chai": "^3.5.0", 29 | "chai-as-promised": "^5.2.0", 30 | "chai-jquery": "^2.0.0", 31 | "chai-string": "^1.2.0", 32 | "coveralls": "^2.11.8", 33 | "csslint": "^0.10.0", 34 | "eslint": "^4.18.2", 35 | "eslint-config-standard": "^5.1.0", 36 | "eslint-plugin-promise": "^1.1.0", 37 | "eslint-plugin-standard": "^1.3.2", 38 | "install": "^0.5.6", 39 | "isparta": "^4.0.0", 40 | "karma": "^0.13.22", 41 | "karma-browserify": "^5.0.2", 42 | "karma-cli": "^0.1.2", 43 | "karma-coverage": "^0.5.5", 44 | "karma-mocha": "^0.2.2", 45 | "karma-phantomjs-launcher": "^1.0.0", 46 | "mocha": "^2.4.5", 47 | "phantomjs-prebuilt": "^2.1.5", 48 | "sinon": "^1.17.3", 49 | "tidy-html5": "latest", 50 | "watchify": "^3.7.0" 51 | }, 52 | "homepage": "https://github.com/pimusicbox/mopidy-musicbox-webclient#readme" 53 | } 54 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/less/mixins.less: -------------------------------------------------------------------------------- 1 | // Mixins 2 | // -------------------------- 3 | 4 | .fa-icon() { 5 | display: inline-block; 6 | font: normal normal normal @fa-font-size-base/@fa-line-height-base FontAwesome; // shortening font declaration 7 | font-size: inherit; // can't have font-size inherit on line above, so need to override 8 | text-rendering: auto; // optimizelegibility throws things off #1094 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | 12 | } 13 | 14 | .fa-icon-rotate(@degrees, @rotation) { 15 | -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=@{rotation})"; 16 | -webkit-transform: rotate(@degrees); 17 | -ms-transform: rotate(@degrees); 18 | transform: rotate(@degrees); 19 | } 20 | 21 | .fa-icon-flip(@horiz, @vert, @rotation) { 22 | -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=@{rotation}, mirror=1)"; 23 | -webkit-transform: scale(@horiz, @vert); 24 | -ms-transform: scale(@horiz, @vert); 25 | transform: scale(@horiz, @vert); 26 | } 27 | 28 | 29 | // Only display content to screen readers. A la Bootstrap 4. 30 | // 31 | // See: http://a11yproject.com/posts/how-to-hide-content/ 32 | 33 | .sr-only() { 34 | position: absolute; 35 | width: 1px; 36 | height: 1px; 37 | padding: 0; 38 | margin: -1px; 39 | overflow: hidden; 40 | clip: rect(0,0,0,0); 41 | border: 0; 42 | } 43 | 44 | // Use in conjunction with .sr-only to only display content when it's focused. 45 | // 46 | // Useful for "Skip to main content" links; see http://www.w3.org/TR/2013/NOTE-WCAG20-TECHS-20130905/G1 47 | // 48 | // Credit: HTML5 Boilerplate 49 | 50 | .sr-only-focusable() { 51 | &:active, 52 | &:focus { 53 | position: static; 54 | width: auto; 55 | height: auto; 56 | margin: 0; 57 | overflow: visible; 58 | clip: auto; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/font_awesome/scss/_mixins.scss: -------------------------------------------------------------------------------- 1 | // Mixins 2 | // -------------------------- 3 | 4 | @mixin fa-icon() { 5 | display: inline-block; 6 | font: normal normal normal #{$fa-font-size-base}/#{$fa-line-height-base} FontAwesome; // shortening font declaration 7 | font-size: inherit; // can't have font-size inherit on line above, so need to override 8 | text-rendering: auto; // optimizelegibility throws things off #1094 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | 12 | } 13 | 14 | @mixin fa-icon-rotate($degrees, $rotation) { 15 | -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=#{$rotation})"; 16 | -webkit-transform: rotate($degrees); 17 | -ms-transform: rotate($degrees); 18 | transform: rotate($degrees); 19 | } 20 | 21 | @mixin fa-icon-flip($horiz, $vert, $rotation) { 22 | -ms-filter: "progid:DXImageTransform.Microsoft.BasicImage(rotation=#{$rotation}, mirror=1)"; 23 | -webkit-transform: scale($horiz, $vert); 24 | -ms-transform: scale($horiz, $vert); 25 | transform: scale($horiz, $vert); 26 | } 27 | 28 | 29 | // Only display content to screen readers. A la Bootstrap 4. 30 | // 31 | // See: http://a11yproject.com/posts/how-to-hide-content/ 32 | 33 | @mixin sr-only { 34 | position: absolute; 35 | width: 1px; 36 | height: 1px; 37 | padding: 0; 38 | margin: -1px; 39 | overflow: hidden; 40 | clip: rect(0,0,0,0); 41 | border: 0; 42 | } 43 | 44 | // Use in conjunction with .sr-only to only display content when it's focused. 45 | // 46 | // Useful for "Skip to main content" links; see http://www.w3.org/TR/2013/NOTE-WCAG20-TECHS-20130905/G1 47 | // 48 | // Credit: HTML5 Boilerplate 49 | 50 | @mixin sr-only-focusable { 51 | &:active, 52 | &:focus { 53 | position: static; 54 | width: auto; 55 | height: auto; 56 | margin: 0; 57 | overflow: visible; 58 | clip: auto; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = Mopidy-MusicBox-Webclient 3 | version = 3.1.0 4 | url = https://github.com/pimusicbox/mopidy-musicbox-webclient 5 | author = Wouter van Wijk 6 | author_email = woutervanwijk@gmail.com 7 | license = Apache License, Version 2.0 8 | license_file = LICENSE 9 | description = Mopidy MusicBox web extension 10 | long_description = file: README.rst 11 | classifiers = 12 | Environment :: No Input/Output (Daemon) 13 | Intended Audience :: End Users/Desktop 14 | License :: OSI Approved :: Apache Software License 15 | Operating System :: OS Independent 16 | Programming Language :: Python :: 3 17 | Programming Language :: Python :: 3.7 18 | Programming Language :: Python :: 3.8 19 | Topic :: Multimedia :: Sound/Audio :: Players 20 | 21 | 22 | [options] 23 | zip_safe = False 24 | include_package_data = True 25 | packages = find: 26 | python_requires = >= 3.7 27 | install_requires = 28 | Mopidy >= 3.0.0 29 | Pykka >= 2.0.1 30 | setuptools 31 | 32 | 33 | [options.extras_require] 34 | lint = 35 | black 36 | check-manifest 37 | flake8 38 | flake8-bugbear 39 | flake8-import-order 40 | isort[pyproject] 41 | release = 42 | twine 43 | wheel 44 | test = 45 | pytest 46 | pytest-cov 47 | node = 48 | nodeenv 49 | dev = 50 | %(lint)s 51 | %(release)s 52 | %(test)s 53 | %(node)s 54 | 55 | 56 | [options.packages.find] 57 | exclude = 58 | tests 59 | tests.* 60 | 61 | 62 | [options.entry_points] 63 | mopidy.ext = 64 | musicbox_webclient = mopidy_musicbox_webclient:Extension 65 | 66 | 67 | [flake8] 68 | application-import-names = mopidy_musicbox_webclient, tests 69 | max-line-length = 80 70 | exclude = .git, .tox, build 71 | select = 72 | # Regular flake8 rules 73 | C, E, F, W 74 | # flake8-bugbear rules 75 | B 76 | # B950: line too long (soft speed limit) 77 | B950 78 | # pep8-naming rules 79 | N 80 | ignore = 81 | # E203: whitespace before ':' (not PEP8 compliant) 82 | E203 83 | # E501: line too long (replaced by B950) 84 | E501 85 | # W503: line break before binary operator (not PEP8 compliant) 86 | W503 87 | # B305: .next() is not a thing on Python 3 (used by playback controller) 88 | B305 89 | 90 | [check-manifest] 91 | ignore = 92 | screenshots 93 | screenshots/* 94 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/web.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import socket 4 | import string 5 | import urllib.parse 6 | 7 | import tornado.web 8 | 9 | import mopidy_musicbox_webclient.webclient as mmw 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class StaticHandler(tornado.web.StaticFileHandler): 15 | def get(self, path, *args, **kwargs): 16 | version = self.get_argument("v", None) 17 | if version: 18 | logger.debug("Get static resource for %s?v=%s", path, version) 19 | else: 20 | logger.debug("Get static resource for %s", path) 21 | return super().get(path, *args, **kwargs) 22 | 23 | @classmethod 24 | def get_version(cls, settings, path): 25 | return mmw.Extension.version 26 | 27 | 28 | class IndexHandler(tornado.web.RequestHandler): 29 | def initialize(self, config, path): 30 | 31 | webclient = mmw.Webclient(config) 32 | 33 | if webclient.is_music_box(): 34 | program_name = "MusicBox" 35 | else: 36 | program_name = "Mopidy" 37 | 38 | url = urllib.parse.urlparse( 39 | f"{self.request.protocol}://{self.request.host}" 40 | ) 41 | port = url.port or 80 42 | try: 43 | ip = socket.getaddrinfo(url.hostname, port)[0][4][0] 44 | except Exception: 45 | ip = url.hostname 46 | 47 | self.__dict = { 48 | "isMusicBox": json.dumps(webclient.is_music_box()), 49 | "websocketUrl": webclient.get_websocket_url(self.request), 50 | "hasAlarmClock": json.dumps(webclient.has_alarm_clock()), 51 | "onTrackClick": webclient.get_default_click_action(), 52 | "programName": program_name, 53 | "hostname": url.hostname, 54 | "serverIP": ip, 55 | "serverPort": port, 56 | } 57 | self.__path = path 58 | self.__title = string.Template(f"{program_name} on $hostname") 59 | 60 | def get(self, path): 61 | return self.render(path, title=self.get_title(), **self.__dict) 62 | 63 | def get_title(self): 64 | url = urllib.parse.urlparse( 65 | f"{self.request.protocol}://{self.request.host}" 66 | ) 67 | return self.__title.safe_substitute(hostname=url.hostname) 68 | 69 | def get_template_path(self): 70 | return self.__path 71 | -------------------------------------------------------------------------------- /tests/js/test_library.js: -------------------------------------------------------------------------------- 1 | var chai = require('chai') 2 | var expect = chai.expect 3 | var assert = chai.assert 4 | chai.use(require('chai-string')) 5 | chai.use(require('chai-jquery')) 6 | 7 | var sinon = require('sinon') 8 | 9 | var library = require('../../mopidy_musicbox_webclient/static/js/library.js') 10 | 11 | describe('Library', function () { 12 | var selectID = '#selectSearchService' 13 | var schemesArray = ['mockScheme1', 'mockScheme2', 'mockScheme3'] 14 | var mopidy = { getUriSchemes: function () { return $.when(schemesArray) } } 15 | 16 | before(function () { 17 | $(document.body).append('') 18 | $('#selectSearchService').selectmenu() 19 | }) 20 | 21 | beforeEach(function () { 22 | uriHumanList = [ 23 | ['mockScheme1', 'mockUriHuman1'], 24 | ['mockScheme2', 'mockUriHuman2'] 25 | ] 26 | }) 27 | describe('#getSearchSchemes()', function () { 28 | beforeEach(function () { 29 | $(selectID).empty() 30 | }) 31 | 32 | it('should add human-readable options for backend schemes', function () { 33 | library.getSearchSchemes([], mopidy) 34 | assert.equal($(selectID).children().length, schemesArray.length + 1) 35 | expect($(selectID).children(':eq(2)')).to.have.text('mockUriHuman2') 36 | }) 37 | 38 | it('should get default value from cookie', function () { 39 | $.cookie('searchScheme', 'mockScheme3') 40 | library.getSearchSchemes([], mopidy) 41 | expect($(selectID + ' option:selected')).to.have.value('mockScheme3') 42 | }) 43 | 44 | it('should default to "all" backends if no cookie is available', function () { 45 | $.removeCookie('searchScheme') 46 | library.getSearchSchemes([], mopidy) 47 | expect($(selectID + ' option:selected')).to.have.value('all') 48 | }) 49 | 50 | it('should capitalize first character of backend schema if no mapping is provided', function () { 51 | library.getSearchSchemes([], mopidy) 52 | expect($(selectID).children(':eq(3)')).to.have.text('MockScheme3') 53 | }) 54 | 55 | it('should blacklist services that should not be searched', function () { 56 | library.getSearchSchemes(['mockScheme2'], mopidy) 57 | assert.equal($(selectID).children().length, schemesArray.length) 58 | expect($(selectID).children()).not.to.contain('mockScheme2') 59 | }) 60 | }) 61 | }) 62 | -------------------------------------------------------------------------------- /tidy.js: -------------------------------------------------------------------------------- 1 | var tidy = require('tidy-html5').tidy_html5 2 | 3 | var fs = require('fs') 4 | 5 | // Traverse directory structure looking for 'html' or 'htm' files. 6 | var getAllHtmlFilesFromFolder = function (dir) { 7 | var filesystem = require('fs') 8 | var results = [] 9 | filesystem.readdirSync(dir).forEach(function (file) { 10 | file = dir + '/' + file 11 | var stat = filesystem.statSync(file) 12 | 13 | if (stat && stat.isDirectory()) { 14 | results = results.concat(getAllHtmlFilesFromFolder(file)) 15 | } else { 16 | var extension = file.substr(file.lastIndexOf('.') + 1).toUpperCase() 17 | if (extension === 'HTM' || extension === 'HTML') { 18 | results.push(file) 19 | } 20 | } 21 | }) 22 | return results 23 | } 24 | 25 | // Read file contents. 26 | function readFiles (dirname, onFileContent) { 27 | var filenames = getAllHtmlFilesFromFolder(dirname) 28 | filenames.forEach(function (filename) { 29 | fs.readFile(filename, 'utf-8', function (err, content) { 30 | if (err) { 31 | throw (err) 32 | } 33 | onFileContent(filename, content) 34 | }) 35 | }) 36 | } 37 | 38 | var util = require('util') 39 | 40 | // Trap stderr output so that we can detect parsing errors. 41 | function hook_stderr (callback) { 42 | var old_write = process.stderr.write 43 | 44 | process.stderr.write = (function (write) { 45 | return function (string, encoding, fd) { 46 | write.apply(process.stdout, arguments) 47 | callback(string, encoding, fd) 48 | } 49 | })(process.stderr.write) 50 | 51 | return function () { 52 | process.stderr.write = old_write 53 | } 54 | } 55 | 56 | var unhook = hook_stderr(function (string, encoding, fd) { 57 | if (string.indexOf('Error:') > 0) { 58 | errors.push(string) 59 | } 60 | }) 61 | 62 | var errorsOccurred = false 63 | var errors = [] 64 | 65 | // Exit with status 1 so that tox can detect errors. 66 | process.on('exit', function () { 67 | if (errorsOccurred === true) { 68 | process.exit(1) 69 | } 70 | }) 71 | 72 | // Start linter 73 | function processFiles (callback) { 74 | console.log('Starting HTML linter...') 75 | readFiles('mopidy_musicbox_webclient/static', function (filename, content) { 76 | console.log('\n' + filename) 77 | var result = tidy(content, {'quiet': true}) 78 | if (errors.length > 0) { 79 | console.error('\nHTML errors detected:\n' + errors.join('')) 80 | errors = [] 81 | errorsOccurred = true 82 | } 83 | }) 84 | } 85 | 86 | processFiles(function () { 87 | unhook() 88 | }) 89 | -------------------------------------------------------------------------------- /tests/test_webclient.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest import mock 3 | 4 | import mopidy.config as mopidy_config 5 | from mopidy_musicbox_webclient import Extension 6 | from mopidy_musicbox_webclient.webclient import Webclient 7 | 8 | 9 | class WebclientTests(unittest.TestCase): 10 | def setUp(self): 11 | config = mopidy_config.Proxy( 12 | { 13 | "musicbox_webclient": { 14 | "enabled": True, 15 | "musicbox": False, 16 | "websocket_host": "host_mock", 17 | "websocket_port": 999, 18 | }, 19 | "alarmclock": {"enabled": True}, 20 | } 21 | ) 22 | 23 | self.ext = Extension() 24 | self.mmw = Webclient(config) 25 | 26 | def test_get_version(self): 27 | assert self.mmw.get_version() == self.ext.version 28 | 29 | def test_get_websocket_url_uses_config_file(self): 30 | assert ( 31 | self.mmw.get_websocket_url(mock.Mock()) 32 | == "ws://host_mock:999/mopidy/ws" 33 | ) 34 | 35 | def test_get_websocket_url_uses_request_host(self): 36 | config = mopidy_config.Proxy( 37 | { 38 | "musicbox_webclient": { 39 | "enabled": True, 40 | "musicbox": False, 41 | "websocket_host": "", 42 | "websocket_port": 999, 43 | } 44 | } 45 | ) 46 | 47 | request_mock = mock.Mock(spec="tornado.HTTPServerRequest") 48 | request_mock.host = "127.0.0.1" 49 | request_mock.protocol = "https" 50 | 51 | self.mmw.config = config 52 | assert ( 53 | self.mmw.get_websocket_url(request_mock) 54 | == "wss://127.0.0.1:999/mopidy/ws" 55 | ) 56 | 57 | def test_get_websocket_url_uses_http_port(self): 58 | config = mopidy_config.Proxy( 59 | { 60 | "http": {"port": 999}, 61 | "musicbox_webclient": { 62 | "enabled": True, 63 | "musicbox": False, 64 | "websocket_host": "127.0.0.1", 65 | "websocket_port": "", 66 | }, 67 | } 68 | ) 69 | 70 | request_mock = mock.Mock(spec="tornado.HTTPServerRequest") 71 | request_mock.host = "127.0.0.1" 72 | request_mock.protocol = "https" 73 | 74 | self.mmw.config = config 75 | assert ( 76 | self.mmw.get_websocket_url(request_mock) 77 | == "wss://127.0.0.1:999/mopidy/ws" 78 | ) 79 | 80 | def test_has_alarmclock(self): 81 | assert self.mmw.has_alarm_clock() 82 | 83 | def test_is_musicbox(self): 84 | assert not self.mmw.is_music_box() 85 | 86 | def test_default_click_action(self): 87 | assert self.mmw.get_default_click_action() == "PLAY_ALL" 88 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | 3 | module.exports = function (config) { 4 | config.set({ 5 | 6 | // base path that will be used to resolve all patterns (eg. files, exclude) 7 | basePath: '', 8 | 9 | // frameworks to use 10 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 11 | frameworks: ['browserify', 'mocha'], 12 | 13 | // list of files / patterns to load in the browser 14 | files: [ 15 | 'mopidy_musicbox_webclient/static/vendors/**/*.js', 16 | 'mopidy_musicbox_webclient/static/js/**/*.js', 17 | 'tests/**/test_*.js' 18 | ], 19 | 20 | // list of files to exclude 21 | exclude: [ 22 | ], 23 | 24 | // preprocess matching files before serving them to the browser 25 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 26 | preprocessors: { 27 | 'tests/**/test_*.js': [ 'browserify' ], 28 | 'mopidy_musicbox_webclient/static/js/**/*.js': ['coverage'] 29 | }, 30 | 31 | // test results reporter to use 32 | // possible values: 'dots', 'progress' 33 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 34 | reporters: ['progress', 'coverage'], 35 | 36 | // web server port 37 | port: 9876, 38 | 39 | // enable / disable colors in the output (reporters and logs) 40 | colors: true, 41 | 42 | // level of logging 43 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 44 | logLevel: config.LOG_INFO, 45 | 46 | // enable / disable watching file and executing tests whenever any file changes 47 | autoWatch: false, 48 | 49 | // start these browsers 50 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 51 | browsers: ['PhantomJS'], 52 | 53 | // Continuous Integration mode 54 | // if true, Karma captures browsers, runs the tests and exits 55 | singleRun: true, 56 | 57 | // Concurrency level 58 | // how many browser should be started simultaneous 59 | concurrency: Infinity, 60 | 61 | // add additional browserify configuration properties here 62 | // such as transform and/or debug=true to generate source maps 63 | browserify: { 64 | debug: true, 65 | transform: [ 66 | 'babelify', 67 | ['browserify-istanbul', { instrumenter: require('isparta') }] 68 | ] 69 | }, 70 | 71 | coverageReporter: { 72 | // specify a common output directory 73 | dir: '.karma_coverage/', 74 | reporters: [ 75 | { type: 'lcov', subdir: '.' }, 76 | { type: 'text' } 77 | ] 78 | } 79 | }) 80 | } 81 | -------------------------------------------------------------------------------- /tests/test_web.py: -------------------------------------------------------------------------------- 1 | from unittest import mock 2 | 3 | import tornado.testing 4 | import tornado.web 5 | import tornado.websocket 6 | 7 | import mopidy.config as config 8 | from mopidy_musicbox_webclient import Extension 9 | from mopidy_musicbox_webclient.web import StaticHandler 10 | 11 | 12 | class BaseTest(tornado.testing.AsyncHTTPTestCase): 13 | def get_app(self): 14 | extension = Extension() 15 | self.config = config.Proxy( 16 | { 17 | "musicbox_webclient": { 18 | "enabled": True, 19 | "musicbox": True, 20 | "websocket_host": "", 21 | "websocket_port": "", 22 | } 23 | } 24 | ) 25 | return tornado.web.Application( 26 | extension.factory(self.config, mock.Mock()) 27 | ) 28 | 29 | 30 | class StaticFileHandlerTest(BaseTest): 31 | def test_static_handler(self): 32 | response = self.fetch("/vendors/mopidy/mopidy.js", method="GET") 33 | 34 | assert response.code == 200 35 | 36 | def test_get_version(self): 37 | assert StaticHandler.get_version(None, None) == Extension.version 38 | 39 | 40 | class RedirectHandlerTest(BaseTest): 41 | def test_redirect_handler(self): 42 | response = self.fetch("/", method="GET", follow_redirects=False) 43 | 44 | assert response.code == 301 45 | response.headers["Location"].endswith("index.html") 46 | 47 | 48 | class IndexHandlerTestMusicBox(BaseTest): 49 | def test_index_handler(self): 50 | response = self.fetch("/index.html", method="GET") 51 | assert response.code == 200 52 | 53 | def test_get_title_musicbox(self): 54 | response = self.fetch("/index.html", method="GET") 55 | body = tornado.escape.to_unicode(response.body) 56 | 57 | assert "MusicBox on 127.0.0.1" in body 58 | 59 | def test_initialize_sets_dictionary_objects(self): 60 | response = self.fetch("/index.html", method="GET") 61 | body = tornado.escape.to_unicode(response.body) 62 | 63 | assert 'data-is-musicbox="true"' in body 64 | assert 'data-has-alarmclock="false"' in body 65 | assert 'data-websocket-url=""' in body 66 | assert 'data-on-track-click="' in body 67 | assert 'data-program-name="' in body 68 | assert 'data-hostname="' in body 69 | 70 | 71 | class IndexHandlerTestMopidy(BaseTest): 72 | def get_app(self): 73 | extension = Extension() 74 | self.config = config.Proxy( 75 | { 76 | "musicbox_webclient": { 77 | "enabled": True, 78 | "musicbox": False, 79 | "websocket_host": "", 80 | "websocket_port": "", 81 | } 82 | } 83 | ) 84 | return tornado.web.Application( 85 | extension.factory(self.config, mock.Mock()) 86 | ) 87 | 88 | def test_initialize_sets_dictionary_objects(self): 89 | response = self.fetch("/index.html", method="GET") 90 | body = tornado.escape.to_unicode(response.body) 91 | 92 | assert 'data-is-musicbox="false"' in body 93 | 94 | def test_get_title_mopidy(self): 95 | response = self.fetch("/index.html", method="GET") 96 | body = tornado.escape.to_unicode(response.body) 97 | 98 | assert "Mopidy on 127.0.0.1" in body 99 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/lastfm/lastfm.api.cache.js: -------------------------------------------------------------------------------- 1 | /* 2 | * 3 | * Copyright (c) 2008-2009, Felix Bruns 4 | * 5 | */ 6 | 7 | /* Set an object on a Storage object. */ 8 | Storage.prototype.setObject = function(key, value){ 9 | this.setItem(key, JSON.stringify(value)); 10 | } 11 | 12 | /* Get an object from a Storage object. */ 13 | Storage.prototype.getObject = function(key){ 14 | var item = this.getItem(key); 15 | 16 | return JSON.parse(item); 17 | } 18 | 19 | /* Creates a new cache object. */ 20 | function LastFMCache(){ 21 | /* Expiration times. */ 22 | var MINUTE = 60; 23 | var HOUR = MINUTE * 60; 24 | var DAY = HOUR * 24; 25 | var WEEK = DAY * 7; 26 | var MONTH = WEEK * 4.34812141; 27 | var YEAR = MONTH * 12; 28 | 29 | /* Methods with weekly expiration. */ 30 | var weeklyMethods = [ 31 | 'artist.getSimilar', 32 | 'tag.getSimilar', 33 | 'track.getSimilar', 34 | 'artist.getTopAlbums', 35 | 'artist.getTopTracks', 36 | 'geo.getTopArtists', 37 | 'geo.getTopTracks', 38 | 'tag.getTopAlbums', 39 | 'tag.getTopArtists', 40 | 'tag.getTopTags', 41 | 'tag.getTopTracks', 42 | 'user.getTopAlbums', 43 | 'user.getTopArtists', 44 | 'user.getTopTags', 45 | 'user.getTopTracks' 46 | ]; 47 | 48 | /* Name for this cache. */ 49 | var name = 'lastfm'; 50 | 51 | /* Create cache if it doesn't exist yet. */ 52 | if(localStorage.getObject(name) == null){ 53 | localStorage.setObject(name, {}); 54 | } 55 | 56 | /* Get expiration time for given parameters. */ 57 | this.getExpirationTime = function(params){ 58 | var method = params.method; 59 | 60 | if((/Weekly/).test(method) && !(/List/).test(method)){ 61 | if(typeof(params.to) != 'undefined' && typeof(params.from) != 'undefined'){ 62 | return YEAR; 63 | } 64 | else{ 65 | return WEEK; 66 | } 67 | } 68 | 69 | for(var key in this.weeklyMethods){ 70 | if(method == this.weeklyMethods[key]){ 71 | return WEEK; 72 | } 73 | } 74 | 75 | return -1; 76 | }; 77 | 78 | /* Check if this cache contains specific data. */ 79 | this.contains = function(hash){ 80 | return typeof(localStorage.getObject(name)[hash]) != 'undefined' && 81 | typeof(localStorage.getObject(name)[hash].data) != 'undefined'; 82 | }; 83 | 84 | /* Load data from this cache. */ 85 | this.load = function(hash){ 86 | return localStorage.getObject(name)[hash].data; 87 | }; 88 | 89 | /* Remove data from this cache. */ 90 | this.remove = function(hash){ 91 | var object = localStorage.getObject(name); 92 | 93 | object[hash] = undefined; 94 | 95 | localStorage.setObject(name, object); 96 | }; 97 | 98 | /* Store data in this cache with a given expiration time. */ 99 | this.store = function(hash, data, expiration){ 100 | var object = localStorage.getObject(name); 101 | var time = Math.round(new Date().getTime() / 1000); 102 | 103 | object[hash] = { 104 | data : data, 105 | expiration : time + expiration 106 | }; 107 | 108 | localStorage.setObject(name, object); 109 | }; 110 | 111 | /* Check if some specific data expired. */ 112 | this.isExpired = function(hash){ 113 | var object = localStorage.getObject(name); 114 | var time = Math.round(new Date().getTime() / 1000); 115 | 116 | if(time > object[hash].expiration){ 117 | return true; 118 | } 119 | 120 | return false; 121 | }; 122 | 123 | /* Clear this cache. */ 124 | this.clear = function(){ 125 | localStorage.setObject(name, {}); 126 | }; 127 | }; -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/jquery_cookie/jquery.cookie.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * jQuery Cookie Plugin v1.4.1 3 | * https://github.com/carhartl/jquery-cookie 4 | * 5 | * Copyright 2006, 2014 Klaus Hartl 6 | * Released under the MIT license 7 | */ 8 | (function (factory) { 9 | if (typeof define === 'function' && define.amd) { 10 | // AMD (Register as an anonymous module) 11 | define(['jquery'], factory); 12 | } else if (typeof exports === 'object') { 13 | // Node/CommonJS 14 | module.exports = factory(require('jquery')); 15 | } else { 16 | // Browser globals 17 | factory(jQuery); 18 | } 19 | }(function ($) { 20 | 21 | var pluses = /\+/g; 22 | 23 | function encode(s) { 24 | return config.raw ? s : encodeURIComponent(s); 25 | } 26 | 27 | function decode(s) { 28 | return config.raw ? s : decodeURIComponent(s); 29 | } 30 | 31 | function stringifyCookieValue(value) { 32 | return encode(config.json ? JSON.stringify(value) : String(value)); 33 | } 34 | 35 | function parseCookieValue(s) { 36 | if (s.indexOf('"') === 0) { 37 | // This is a quoted cookie as according to RFC2068, unescape... 38 | s = s.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\'); 39 | } 40 | 41 | try { 42 | // Replace server-side written pluses with spaces. 43 | // If we can't decode the cookie, ignore it, it's unusable. 44 | // If we can't parse the cookie, ignore it, it's unusable. 45 | s = decodeURIComponent(s.replace(pluses, ' ')); 46 | return config.json ? JSON.parse(s) : s; 47 | } catch(e) {} 48 | } 49 | 50 | function read(s, converter) { 51 | var value = config.raw ? s : parseCookieValue(s); 52 | return $.isFunction(converter) ? converter(value) : value; 53 | } 54 | 55 | var config = $.cookie = function (key, value, options) { 56 | 57 | // Write 58 | 59 | if (arguments.length > 1 && !$.isFunction(value)) { 60 | options = $.extend({}, config.defaults, options); 61 | 62 | if (typeof options.expires === 'number') { 63 | var days = options.expires, t = options.expires = new Date(); 64 | t.setMilliseconds(t.getMilliseconds() + days * 864e+5); 65 | } 66 | 67 | return (document.cookie = [ 68 | encode(key), '=', stringifyCookieValue(value), 69 | options.expires ? '; expires=' + options.expires.toUTCString() : '', // use expires attribute, max-age is not supported by IE 70 | options.path ? '; path=' + options.path : '', 71 | options.domain ? '; domain=' + options.domain : '', 72 | options.secure ? '; secure' : '' 73 | ].join('')); 74 | } 75 | 76 | // Read 77 | 78 | var result = key ? undefined : {}, 79 | // To prevent the for loop in the first place assign an empty array 80 | // in case there are no cookies at all. Also prevents odd result when 81 | // calling $.cookie(). 82 | cookies = document.cookie ? document.cookie.split('; ') : [], 83 | i = 0, 84 | l = cookies.length; 85 | 86 | for (; i < l; i++) { 87 | var parts = cookies[i].split('='), 88 | name = decode(parts.shift()), 89 | cookie = parts.join('='); 90 | 91 | if (key === name) { 92 | // If second argument (value) is a function it's a converter... 93 | result = read(cookie, value); 94 | break; 95 | } 96 | 97 | // Prevent storing a cookie that we couldn't decode. 98 | if (!key && (cookie = read(cookie)) !== undefined) { 99 | result[name] = cookie; 100 | } 101 | } 102 | 103 | return result; 104 | }; 105 | 106 | config.defaults = {}; 107 | 108 | $.removeCookie = function (key, options) { 109 | // Must not alter options, thus extending a fresh object... 110 | $.cookie(key, '', $.extend({}, options, { expires: -1 })); 111 | return !$.cookie(key); 112 | }; 113 | 114 | })); -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/mb.appcache: -------------------------------------------------------------------------------- 1 | CACHE MANIFEST 2 | 3 | # 2017-10-07:v1 4 | 5 | NETWORK: 6 | * 7 | 8 | CACHE: 9 | css/webclient.css 10 | dialog-success.html 11 | images/default_cover.png 12 | images/empty.png 13 | images/icons/musicbox114.png 14 | images/icons/musicbox32.gif 15 | images/icons/musicbox32.png 16 | images/icons/musicbox57.png 17 | images/icons/musicbox72.png 18 | images/icons/play_alt_12x12.png 19 | images/icons/play_alt_16x16.png 20 | images/loader.gif 21 | images/user_24x32.png 22 | js/controls.js 23 | js/custom_scripting.js 24 | js/functionsvars.js 25 | js/gui.js 26 | js/images.js 27 | js/library.js 28 | js/process_ws.js 29 | js/synced_timer.js 30 | mb.appcache 31 | system.html 32 | vendors/font_awesome/css/font-awesome.css 33 | vendors/font_awesome/css/font-awesome.min.css 34 | vendors/font_awesome/fonts/fontawesome-webfont.eot 35 | vendors/font_awesome/fonts/fontawesome-webfont.svg 36 | vendors/font_awesome/fonts/fontawesome-webfont.ttf 37 | vendors/font_awesome/fonts/fontawesome-webfont.woff 38 | vendors/font_awesome/fonts/fontawesome-webfont.woff2 39 | vendors/font_awesome/fonts/FontAwesome.otf 40 | vendors/font_awesome/less/animated.less 41 | vendors/font_awesome/less/bordered-pulled.less 42 | vendors/font_awesome/less/core.less 43 | vendors/font_awesome/less/fixed-width.less 44 | vendors/font_awesome/less/font-awesome.less 45 | vendors/font_awesome/less/icons.less 46 | vendors/font_awesome/less/larger.less 47 | vendors/font_awesome/less/list.less 48 | vendors/font_awesome/less/mixins.less 49 | vendors/font_awesome/less/path.less 50 | vendors/font_awesome/less/rotated-flipped.less 51 | vendors/font_awesome/less/screen-reader.less 52 | vendors/font_awesome/less/stacked.less 53 | vendors/font_awesome/less/variables.less 54 | vendors/font_awesome/scss/_animated.scss 55 | vendors/font_awesome/scss/_bordered-pulled.scss 56 | vendors/font_awesome/scss/_core.scss 57 | vendors/font_awesome/scss/_fixed-width.scss 58 | vendors/font_awesome/scss/_icons.scss 59 | vendors/font_awesome/scss/_larger.scss 60 | vendors/font_awesome/scss/_list.scss 61 | vendors/font_awesome/scss/_mixins.scss 62 | vendors/font_awesome/scss/_path.scss 63 | vendors/font_awesome/scss/_rotated-flipped.scss 64 | vendors/font_awesome/scss/_screen-reader.scss 65 | vendors/font_awesome/scss/_stacked.scss 66 | vendors/font_awesome/scss/_variables.scss 67 | vendors/font_awesome/scss/font-awesome.scss 68 | vendors/jquery/jquery-1.12.0.min.js 69 | vendors/jquery_cookie/jquery.cookie.js 70 | vendors/jquery_mobile/images/ajax-loader.gif 71 | vendors/jquery_mobile/images/icons-18-black.png 72 | vendors/jquery_mobile/images/icons-18-white.png 73 | vendors/jquery_mobile/images/icons-36-black.png 74 | vendors/jquery_mobile/images/icons-36-white.png 75 | vendors/jquery_mobile/index.html 76 | vendors/jquery_mobile/jquery.mobile-1.3.2.css 77 | vendors/jquery_mobile/jquery.mobile-1.3.2.js 78 | vendors/jquery_mobile/jquery.mobile-1.3.2.min.css 79 | vendors/jquery_mobile/jquery.mobile-1.3.2.min.js 80 | vendors/jquery_mobile/jquery.mobile-1.3.2.min.map 81 | vendors/jquery_mobile/jquery.mobile.structure-1.3.2.css 82 | vendors/jquery_mobile/jquery.mobile.structure-1.3.2.min.css 83 | vendors/jquery_mobile/jquery.mobile.theme-1.3.2.css 84 | vendors/jquery_mobile/jquery.mobile.theme-1.3.2.min.css 85 | vendors/jquery_mobile_flat_ui_theme/fonts/Flat-UI-Icons-24.ttf 86 | vendors/jquery_mobile_flat_ui_theme/fonts/Flat-UI-Icons-24.woff 87 | vendors/jquery_mobile_flat_ui_theme/fonts/lato-black.ttf 88 | vendors/jquery_mobile_flat_ui_theme/fonts/lato-black.woff 89 | vendors/jquery_mobile_flat_ui_theme/fonts/lato-bold.ttf 90 | vendors/jquery_mobile_flat_ui_theme/fonts/lato-bold.woff 91 | vendors/jquery_mobile_flat_ui_theme/fonts/lato-italic.ttf 92 | vendors/jquery_mobile_flat_ui_theme/fonts/lato-italic.woff 93 | vendors/jquery_mobile_flat_ui_theme/fonts/lato-regular.ttf 94 | vendors/jquery_mobile_flat_ui_theme/fonts/lato-regular.woff 95 | vendors/jquery_mobile_flat_ui_theme/images/ajax-loader.gif 96 | vendors/jquery_mobile_flat_ui_theme/images/icons-18-black.png 97 | vendors/jquery_mobile_flat_ui_theme/images/icons-18-white.png 98 | vendors/jquery_mobile_flat_ui_theme/images/icons-36-black.png 99 | vendors/jquery_mobile_flat_ui_theme/images/icons-36-white.png 100 | vendors/jquery_mobile_flat_ui_theme/jquery.mobile.flatui.css 101 | vendors/jquery_mobile_flat_ui_theme/jquery.mobile.flatui.min.css 102 | vendors/lastfm/lastfm.api.cache.js 103 | vendors/lastfm/lastfm.api.js 104 | vendors/lastfm/lastfm.api.md5.js 105 | vendors/media_progress_timer/timer.js 106 | vendors/mopidy/mopidy.js 107 | vendors/mopidy/mopidy.min.js 108 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ************************* 2 | Mopidy-MusicBox-Webclient 3 | ************************* 4 | 5 | .. image:: https://img.shields.io/pypi/v/Mopidy-MusicBox-Webclient.svg 6 | :target: https://pypi.org/project/Mopidy-MusicBox-Webclient/ 7 | :alt: Latest PyPI version 8 | 9 | .. image:: https://img.shields.io/circleci/project/pimusicbox/mopidy-musicbox-webclient/develop.svg 10 | :target: https://circleci.com/gh/pimusicbox/mopidy-musicbox-webclient 11 | :alt: CircleCI build status 12 | 13 | .. image:: https://img.shields.io/codecov/c/github/pimusicbox/mopidy-musicbox-webclient/develop.svg 14 | :target: https://codecov.io/gh/pimusicbox/mopidy-musicbox-webclient 15 | :alt: Test coverage 16 | 17 | .. image:: https://img.shields.io/badge/code%20style-standard-brightgreen.svg 18 | :target: http://standardjs.com/ 19 | :alt: JavaScript Standard Style 20 | 21 | Mopidy MusicBox Webclient (MMW) is a frontend extension and JavaScript-based web client especially written for 22 | `Mopidy `_. 23 | 24 | Features 25 | ======== 26 | 27 | - Responsive design that works equally well on desktop and mobile browsers. 28 | - Browse content provided by any Mopidy backend extension. 29 | - Add one or more tracks or entire albums to the queue. 30 | - Save the current queue to an easily accessible playlist. 31 | - Search for tracks, albums, or artists from specific backends or all of Mopidy. 32 | - Shows detailed track and album information during playback, with album cover retrieval from Last.fm. 33 | - Support for all of the Mopidy playback controls (consume mode, repeat, shuffle, etc.) 34 | - Deep integration with, and additional features for, the `Pi MusicBox `_. 35 | - Fullscreen mode. 36 | 37 | .. image:: https://github.com/pimusicbox/mopidy-musicbox-webclient/raw/develop/screenshots/overview.png 38 | :width: 1312 39 | :height: 723 40 | 41 | Dependencies 42 | ============ 43 | 44 | - MMW has been tested on the major browsers (Chrome, IE, Firefox, Safari, iOS). It *may* also work on other browsers 45 | that support websockets, cookies, and JavaScript. 46 | 47 | - ``Mopidy`` >= 3.0.0. An extensible music server that plays music from local disk, Spotify, SoundCloud, Google 48 | Play Music, and more. 49 | 50 | Installation 51 | ============ 52 | 53 | Install by running:: 54 | 55 | sudo python3 -m pip install Mopidy-MusicBox-Webclient 56 | 57 | Or, if available, install the Debian/Ubuntu package from 58 | `apt.mopidy.com `_. 59 | 60 | 61 | Configuration 62 | ============= 63 | 64 | MMW is shipped with default settings that should work straight out of the box for most users:: 65 | 66 | [musicbox_webclient] 67 | enabled = true 68 | musicbox = false 69 | websocket_host = 70 | websocket_port = 71 | on_track_click = PLAY_ALL 72 | 73 | The following configuration values are available should you wish to customize your installation further: 74 | 75 | - ``musicbox_webclient/enabled``: If the MMW extension should be enabled or not. Defaults to ``true``. 76 | 77 | - ``musicbox_webclient/musicbox``: Set this to ``true`` if you are connecting to a Mopidy instance running on a 78 | Pi Musicbox. Expands the MMW user interface to include system control/configuration functionality. 79 | 80 | - ``musicbox_webclient/websocket_host``: Optional setting to specify the target host for Mopidy websocket connections. 81 | 82 | - ``musicbox_webclient/websocket_port``: Optional setting to specify the target port for Mopidy websocket connections. 83 | 84 | - ``musicbox_webclient/on_track_click``: The action performed when clicking on a track. Valid options are: 85 | ``PLAY_ALL`` (default), ``PLAY_NOW``, ``PLAY_NEXT``, ``ADD_THIS_BOTTOM``, ``ADD_ALL_BOTTOM``, and ``DYNAMIC`` (repeats last action). 86 | 87 | Usage 88 | ===== 89 | 90 | Enter the address of the Mopidy server that you are connecting to in your browser (e.g. http://localhost:6680/musicbox_webclient) 91 | 92 | 93 | Project resources 94 | ================= 95 | 96 | - `Source code `_ 97 | - `Issue tracker `_ 98 | - `Changelog `_ 99 | 100 | Credits 101 | ======= 102 | 103 | - Original author: `Wouter van Wijk `__ 104 | - Current maintainer: `Nick Steel `__ 105 | - `Contributors `_ 106 | -------------------------------------------------------------------------------- /tests/js/dummy_tracklist.js: -------------------------------------------------------------------------------- 1 | (function (root, factory) { 2 | if (typeof define === 'function' && define.amd) { 3 | define([], factory) 4 | } else if (typeof module === 'object' && module.exports) { 5 | module.exports = factory() 6 | } else { 7 | root.DummyTracklist = factory() 8 | } 9 | }(this, function () { 10 | 'use strict' 11 | 12 | /* A dummy tracklist with partial support for mocking mopidy.core.TracklistController. 13 | * 14 | * Returns resolved promises to simulate functionality of Mopidy.js. 15 | */ 16 | function DummyTracklist () { 17 | if (!(this instanceof DummyTracklist)) { 18 | return new DummyTracklist() 19 | } 20 | this._tlTracks = [] 21 | this._nextTlid = 1 22 | return this 23 | } 24 | 25 | /* Add tracks to the tracklist. params.uris should contain an array of strings for the URIs to be added. */ 26 | DummyTracklist.prototype.add = function (params) { 27 | if (!params || !params.uris) { 28 | throw new Error('No tracks provided to add.') 29 | } 30 | if (params.tracks || params.uri) { 31 | throw new Error('DummyTracklist.add does not support deprecated "tracks" and "uri" parameters.') 32 | } 33 | 34 | var position = params.at_position 35 | // Add tracks to end of tracklist if no position is provided 36 | if (typeof position === 'undefined') { 37 | position = Math.max(0, this._tlTracks.length) 38 | } 39 | 40 | var tlTrack 41 | for (var i = 0; i < params.uris.length; i++) { 42 | tlTrack = { 43 | tlid: this._nextTlid++, 44 | track: { 45 | uri: params.uris[i] 46 | } 47 | } 48 | this._tlTracks.splice(position++, 0, tlTrack) 49 | } 50 | 51 | return $.when(this._tlTracks) 52 | } 53 | 54 | /* Clears the tracklist */ 55 | DummyTracklist.prototype.clear = function () { 56 | this._tlTracks = [] 57 | } 58 | 59 | /* Remove the matching tracks from the tracklist */ 60 | DummyTracklist.prototype.remove = function (criteria) { 61 | this.filter(criteria).then( function (matches) { 62 | for (var i = 0; i < matches.length; i++) { 63 | for (var j = 0; j < this._tlTracks.length; j++) { 64 | if (this._tlTracks[j].track.uri === matches[i].track.uri) { 65 | this._tlTracks.splice(j, 1) 66 | } 67 | } 68 | } 69 | }.bind(this)) 70 | 71 | return $.when(this._tlTracks) 72 | } 73 | 74 | /** 75 | * Retuns a list containing tlTracks that contain the provided 76 | * criteria.uri or has ID criteria.tlid. 77 | * 78 | */ 79 | DummyTracklist.prototype.filter = function (criteria) { 80 | criteria = criteria.criteria 81 | if (!criteria || (!criteria.uri && !criteria.tlid)) { 82 | throw new Error('No URI or tracklist ID provided to filter on.') 83 | } 84 | 85 | var matches = [] 86 | if (criteria.uri) { // Look for matching URIs 87 | for (var i = 0; i < criteria.uri.length; i++) { 88 | for (var j = 0; j < this._tlTracks.length; j++) { 89 | if (this._tlTracks[j].track.uri === criteria.uri[i]) { 90 | matches.push(this._tlTracks[j]) 91 | } 92 | } 93 | } 94 | } 95 | if (criteria.tlid) { // Look for matching tracklist IDs 96 | for (i = 0; i < criteria.tlid.length; i++) { 97 | for (j = 0; j < this._tlTracks.length; j++) { 98 | if (this._tlTracks[j].tlid === criteria.tlid[i]) { 99 | matches.push(this._tlTracks[j]) 100 | } 101 | } 102 | } 103 | } 104 | return $.when(matches) 105 | } 106 | 107 | /* Retuns index of the currently 'playing' track. */ 108 | DummyTracklist.prototype.index = function (params) { 109 | if (!params) { 110 | if (this._tlTracks.length > 0) { 111 | // Always just assume that the first track is playing 112 | return $.when(0) 113 | } else { 114 | return $.when(null) 115 | } 116 | } 117 | for (var i = 0; i < this._tlTracks.length; i++) { 118 | if (this._tlTracks[i].tlid === params.tlid || (params.tl_track && params.tl_track.tlid === this._tlTracks[i].tlid)) { 119 | return $.when(i) 120 | } 121 | } 122 | return $.when(null) 123 | } 124 | 125 | /* Returns the tracks in the tracklist */ 126 | DummyTracklist.prototype.get_tl_tracks = function () { 127 | return $.when(this._tlTracks) 128 | } 129 | 130 | /* Returns the length of the tracklist */ 131 | DummyTracklist.prototype.get_length = function () { 132 | return $.when(this._tlTracks.length) 133 | } 134 | 135 | return DummyTracklist 136 | })) 137 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/media_progress_timer/timer.js: -------------------------------------------------------------------------------- 1 | /*! timer.js v3.0.0 2 | * https://github.com/adamcik/media-progress-timer 3 | * Copyright (c) 2015-2016 Thomas Adamcik 4 | * Licensed under the Apache License, Version 2.0 */ 5 | 6 | (function (root, factory) { 7 | if (typeof define === 'function' && define.amd) { 8 | define([], factory); 9 | } else if (typeof module === 'object' && module.exports) { 10 | module.exports = factory(); 11 | } else { 12 | root.ProgressTimer = factory(); 13 | } 14 | }(this, function () { 15 | 'use strict'; 16 | 17 | // Helper function to provide a reference time in milliseconds. 18 | var now = /* Sinon does not currently support faking `window.performance` 19 | (see https://github.com/sinonjs/sinon/issues/803). 20 | Changing this to only rely on `new Date().getTime() 21 | in the interim in order to allow testing of the 22 | progress timer from MMW. 23 | 24 | typeof window.performance !== 'undefined' && 25 | typeof window.performance.now !== 'undefined' && 26 | window.performance.now.bind(window.performance) || Date.now ||*/ 27 | function() { return new Date().getTime(); }; 28 | 29 | // Helper to warn library users about deprecated features etc. 30 | function warn(msg) { 31 | window.setTimeout(function() { throw msg; }, 0); 32 | } 33 | 34 | // Creates a new timer object, works with both 'new ProgressTimer(options)' 35 | // and just 'ProgressTimer(options). Optionally the timer can also be 36 | // called with only the callback instead of options. 37 | function ProgressTimer(options) { 38 | if (!(this instanceof ProgressTimer)) { 39 | return new ProgressTimer(options); 40 | } else if (typeof options === 'function') { 41 | options = {'callback': options}; 42 | } else if (typeof options !== 'object') { 43 | throw '"ProgressTimer" must be called with a callback or options.'; 44 | } else if (typeof options['callback'] !== 'function') { 45 | throw '"ProgressTimer" needs a callback to operate.'; 46 | } 47 | 48 | this._userCallback = options['callback']; 49 | this._updateId = null; 50 | this._state = null; // Gets initialized by the set() call. 51 | 52 | var frameDuration = 1000 / (options['fallbackTargetFrameRate'] || 30); 53 | // TODO: Remove this legacy code path at some point. 54 | if (options['updateRate'] && !options['fallbackTargetFrameRate']) { 55 | warn('"ProgressTimer" no longer supports the updateRate option.'); 56 | frameDuration = Math.max(options['updateRate'], 1000 / 60); 57 | } 58 | 59 | var useFallback = ( 60 | typeof window.requestAnimationFrame === 'undefined' || 61 | typeof window.cancelAnimationFrame === 'undefined' || 62 | options['disableRequestAnimationFrame'] || false); 63 | 64 | // Make sure this works in _update. 65 | var update = this._update.bind(this); 66 | 67 | if (useFallback) { 68 | this._schedule = function(timestamp) { 69 | var timeout = Math.max(timestamp + frameDuration - now(), 0); 70 | return window.setTimeout(update, Math.floor(timeout)); 71 | }; 72 | this._cancel = window.clearTimeout.bind(window); 73 | } else { 74 | this._schedule = window.requestAnimationFrame.bind(window, update); 75 | this._cancel = window.cancelAnimationFrame.bind(window); 76 | } 77 | 78 | this.reset(); // Reuse reset code to ensure we start in the same state. 79 | } 80 | 81 | // If called with one argument the previous duration is preserved. Note 82 | // that the position can be changed while the timer is running. 83 | ProgressTimer.prototype.set = function(position, duration) { 84 | if (arguments.length === 0) { 85 | throw '"ProgressTimer.set" requires the "position" arugment.'; 86 | } else if (arguments.length === 1) { 87 | // Fallback to previous duration, whatever that was. 88 | duration = this._state.duration; 89 | } else { 90 | // Round down and make sure zero and null are treated as inf. 91 | duration = Math.floor(Math.max( 92 | duration === null ? Infinity : duration || Infinity, 0)); 93 | } 94 | // Make sure '0 <= position <= duration' always holds. 95 | position = Math.floor(Math.min(Math.max(position || 0, 0), duration)); 96 | 97 | this._state = { 98 | initialTimestamp: null, 99 | initialPosition: position, 100 | position: position, 101 | duration: duration 102 | }; 103 | 104 | // Update right away if we don't have anything running. 105 | if (this._updateId === null) { 106 | // TODO: Consider wrapping this in a try/catch? 107 | this._userCallback(position, duration); 108 | } 109 | return this; 110 | }; 111 | 112 | // Start the timer if it is not already running. 113 | ProgressTimer.prototype.start = function() { 114 | if (this._updateId === null) { 115 | this._updateId = this._schedule(0); 116 | } 117 | return this; 118 | }; 119 | 120 | // Cancel the timer if it us currently tracking progress. 121 | ProgressTimer.prototype.stop = function() { 122 | if (this._updateId !== null) { 123 | this._cancel(this._updateId); 124 | 125 | // Ensure we correctly reset the initial position and timestamp. 126 | this.set(this._state.position, this._state.duration); 127 | this._updateId = null; // Last step to avoid callback in set() 128 | } 129 | return this; 130 | }; 131 | 132 | // Marks the timer as stopped, sets position to zero and duration to inf. 133 | ProgressTimer.prototype.reset = function() { 134 | return this.stop().set(0, Infinity); 135 | }; 136 | 137 | // Calls the user callback with the current position/duration and then 138 | // schedules the next update run via _schedule if we haven't finished. 139 | ProgressTimer.prototype._update = function(timestamp) { 140 | var state = this._state; // We refer a lot to state, this is shorter. 141 | 142 | // Make sure setTimeout has a timestamp and store first reference time. 143 | timestamp = timestamp || now(); 144 | state.initialTimestamp = state.initialTimestamp || timestamp; 145 | 146 | // Recalculate position according to start location and reference. 147 | state.position = ( 148 | state.initialPosition + timestamp - state.initialTimestamp); 149 | 150 | // Ensure callback gets an integer and that 'position <= duration'. 151 | var userPosisition = Math.min( 152 | Math.floor(state.position), state.duration); 153 | 154 | // TODO: Consider wrapping this in a try/catch? 155 | this._userCallback(userPosisition, state.duration); 156 | // Workaround for https://github.com/adamcik/media-progress-timer/issues/3 157 | // Mopidy <= 1.1.2 does not always return the correct track position as 158 | // track changes are being done, which can cause the timer to die unexpectedly. 159 | //if (state.position < state.duration) { 160 | this._updateId = this._schedule(timestamp); // Schedule update. 161 | //} else { 162 | // this._updateId = null; // Unset since we didn't reschedule. 163 | //} 164 | }; 165 | 166 | return ProgressTimer; 167 | })); -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/js/synced_timer.js: -------------------------------------------------------------------------------- 1 | (function (root, factory) { 2 | if (typeof define === 'function' && define.amd) { 3 | define([], factory) 4 | } else if (typeof module === 'object' && module.exports) { 5 | module.exports = factory() 6 | } else { 7 | root.SyncedProgressTimer = factory() 8 | } 9 | }(this, function () { 10 | 'use strict' 11 | 12 | function delay_exponential (base, growthFactor, attempts) { 13 | /* Calculate number of beats between syncs based on exponential function. 14 | The format is:: 15 | 16 | base * growthFactor ^ (attempts - 1) 17 | 18 | If ``base`` is set to 'rand' then a random number between 19 | 0 and 1 will be used as the base. 20 | Base must be greater than 0. 21 | */ 22 | if (base === 'rand') { 23 | base = Math.random() 24 | } 25 | // console.log(base + ' * (Math.pow(' + growthFactor + ', (' + attempts + ' - 1)) = ' + base * (Math.pow(growthFactor, (attempts - 1)))) 26 | return base * (Math.pow(growthFactor, (attempts - 1))) 27 | } 28 | 29 | function SyncedProgressTimer (maxAttempts, mopidy) { 30 | if (!(this instanceof SyncedProgressTimer)) { 31 | return new SyncedProgressTimer(maxAttempts, mopidy) 32 | } 33 | 34 | this.positionNode = document.createTextNode('') 35 | this.durationNode = document.createTextNode('') 36 | 37 | $('#songelapsed').empty().append(this.positionNode) 38 | $('#songlength').empty().append(this.durationNode) 39 | 40 | this._progressTimer = new ProgressTimer({ 41 | // Make sure that the timer object's context is available. 42 | callback: $.proxy(this.timerCallback, this) 43 | }) 44 | 45 | this._maxAttempts = maxAttempts 46 | this._mopidy = mopidy 47 | this._isConnected = false 48 | this._mopidy.on('state:online', $.proxy(function () { this._isConnected = true }), this) 49 | this._mopidy.on('state:offline', $.proxy(function () { this._isConnected = false }), this) 50 | this.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED 51 | this._isSyncScheduled = false 52 | this._scheduleID = null 53 | this._syncAttemptsRemaining = this._maxAttempts 54 | this._previousSyncPosition = null 55 | this._duration = null 56 | } 57 | 58 | SyncedProgressTimer.SYNC_STATE = { 59 | NOT_SYNCED: 0, 60 | SYNCING: 1, 61 | SYNCED: 2 62 | } 63 | 64 | SyncedProgressTimer.format = function (milliseconds) { 65 | if (milliseconds === Infinity) { 66 | return '' 67 | } else if (milliseconds === 0) { 68 | return '0:00' 69 | } 70 | 71 | var seconds = Math.floor(milliseconds / 1000) 72 | var minutes = Math.floor(seconds / 60) 73 | seconds = seconds % 60 74 | 75 | seconds = seconds < 10 ? '0' + seconds : seconds 76 | return minutes + ':' + seconds 77 | } 78 | 79 | SyncedProgressTimer.prototype.timerCallback = function (position, duration) { 80 | this._update(position, duration) 81 | if (this._isSyncScheduled && this._isConnected) { 82 | this._doSync(position, duration) 83 | } 84 | } 85 | 86 | SyncedProgressTimer.prototype._update = function (position, duration) { 87 | switch (this.syncState) { 88 | case SyncedProgressTimer.SYNC_STATE.NOT_SYNCED: 89 | // Waiting for Mopidy to provide a target position. 90 | this.positionNode.nodeValue = '(wait)' 91 | break 92 | case SyncedProgressTimer.SYNC_STATE.SYNCING: 93 | // Busy seeking to new target position. 94 | this.positionNode.nodeValue = '(sync)' 95 | break 96 | case SyncedProgressTimer.SYNC_STATE.SYNCED: 97 | this._previousSyncPosition = position 98 | this.positionNode.nodeValue = SyncedProgressTimer.format(position) 99 | $('#trackslider').val(position).slider('refresh') 100 | break 101 | } 102 | } 103 | 104 | SyncedProgressTimer.prototype._scheduleSync = function (milliseconds) { 105 | // Use an anonymous callback to set a boolean value, which should be faster to 106 | // check in the timeout callback than doing another function call. 107 | clearTimeout(this._scheduleID) 108 | this._isSyncScheduled = false 109 | if (milliseconds >= 0) { 110 | this._scheduleID = setTimeout($.proxy(function () { this._isSyncScheduled = true }, this), milliseconds) 111 | } 112 | } 113 | 114 | SyncedProgressTimer.prototype._doSync = function (position, duration) { 115 | var ready = !(duration === Infinity && position === 0) // Timer has been properly initialized. 116 | if (!ready) { 117 | // Don't try to sync if progress timer has not been initialized yet. 118 | return 119 | } 120 | 121 | this._scheduleSync(-1) // Ensure that only one sync process is active at a time. 122 | 123 | var _this = this 124 | _this._mopidy.playback.getTimePosition().then(function (targetPosition) { 125 | if (_this.syncState === SyncedProgressTimer.SYNC_STATE.NOT_SYNCED) { 126 | _this.syncState = SyncedProgressTimer.SYNC_STATE.SYNCING 127 | } 128 | if (Math.abs(targetPosition - position) <= 500) { 129 | // Less than 500ms == in sync. 130 | _this._syncAttemptsRemaining = Math.max(_this._syncAttemptsRemaining - 1, 0) 131 | if (_this._syncAttemptsRemaining < _this._maxAttempts - 1 && _this._previousSyncPosition !== targetPosition) { 132 | // Need at least two consecutive syncs to know that Mopidy 133 | // is progressing playback and we are in sync. 134 | _this.syncState = SyncedProgressTimer.SYNC_STATE.SYNCED 135 | } 136 | _this._previousSyncPosition = targetPosition 137 | // Step back exponentially while increasing number of callbacks. 138 | _this._scheduleSync(delay_exponential(0.25, 2, _this._maxAttempts - _this._syncAttemptsRemaining) * 1000) 139 | } else { 140 | // Drift is too large, re-sync with Mopidy. 141 | _this.syncState = SyncedProgressTimer.SYNC_STATE.SYNCING 142 | _this._syncAttemptsRemaining = _this._maxAttempts 143 | _this._previousSyncPosition = null 144 | _this._scheduleSync(1000) 145 | _this._progressTimer.set(targetPosition) 146 | } 147 | }) 148 | } 149 | 150 | SyncedProgressTimer.prototype.set = function (position, duration) { 151 | if (arguments.length === 0) { 152 | throw new Error('"SyncedProgressTimer.set" requires the "position" argument.') 153 | } 154 | 155 | this.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED 156 | this._syncAttemptsRemaining = this._maxAttempts 157 | // Workaround for https://github.com/adamcik/media-progress-timer/issues/3 158 | // This causes the timer to die unexpectedly if the position exceeds 159 | // the duration slightly. 160 | if (this._duration && this._duration < position) { 161 | position = this._duration - 1 162 | } 163 | if (arguments.length === 1) { 164 | this._progressTimer.set(position) 165 | } else { 166 | this._duration = duration 167 | this._progressTimer.set(position, duration) 168 | this.durationNode.nodeValue = SyncedProgressTimer.format(duration) 169 | } 170 | 171 | this.updatePosition(position, duration) 172 | $('#trackslider').val(position).slider('refresh') 173 | 174 | return this 175 | } 176 | 177 | SyncedProgressTimer.prototype.start = function () { 178 | this.syncState = SyncedProgressTimer.SYNC_STATE.NOT_SYNCED 179 | this._scheduleSync(0) 180 | this._progressTimer.start() 181 | return this 182 | } 183 | 184 | SyncedProgressTimer.prototype.stop = function () { 185 | this._progressTimer.stop() 186 | this._scheduleSync(-1) 187 | if (this.syncState !== SyncedProgressTimer.SYNC_STATE.SYNCED && this._previousSyncPosition) { 188 | // Timer was busy trying to sync when it was stopped, fallback to displaying the last synced position on screen. 189 | this.positionNode.nodeValue = SyncedProgressTimer.format(this._previousSyncPosition) 190 | } 191 | return this 192 | } 193 | 194 | SyncedProgressTimer.prototype.reset = function () { 195 | this.stop() 196 | this.set(0, Infinity) 197 | 198 | return this 199 | } 200 | 201 | SyncedProgressTimer.prototype.updatePosition = function (position) { 202 | if (!(this._duration === Infinity && position === 0)) { 203 | this.positionNode.nodeValue = SyncedProgressTimer.format(position) 204 | } else { 205 | this.positionNode.nodeValue = '' 206 | } 207 | } 208 | 209 | return SyncedProgressTimer 210 | })) 211 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/vendors/lastfm/lastfm.api.md5.js: -------------------------------------------------------------------------------- 1 | /* 2 | * A JavaScript implementation of the RSA Data Security, Inc. MD5 Message 3 | * Digest Algorithm, as defined in RFC 1321. 4 | * Version 2.1 Copyright (C) Paul Johnston 1999 - 2002. 5 | * Other contributors: Greg Holt, Andrew Kepert, Ydnar, Lostinet 6 | * Distributed under the BSD License 7 | * See http://pajhome.org.uk/crypt/md5 for more info. 8 | */ 9 | 10 | /* 11 | * Configurable variables. You may need to tweak these to be compatible with 12 | * the server-side, but the defaults work in most cases. 13 | */ 14 | var hexcase = 0; /* hex output format. 0 - lowercase; 1 - uppercase */ 15 | var b64pad = ""; /* base-64 pad character. "=" for strict RFC compliance */ 16 | var chrsz = 8; /* bits per input character. 8 - ASCII; 16 - Unicode */ 17 | 18 | /* 19 | * These are the functions you'll usually want to call 20 | * They take string arguments and return either hex or base-64 encoded strings 21 | */ 22 | function md5(s){ return hex_md5(s); } 23 | function hex_md5(s){ return binl2hex(core_md5(str2binl(s), s.length * chrsz));} 24 | function b64_md5(s){ return binl2b64(core_md5(str2binl(s), s.length * chrsz));} 25 | function str_md5(s){ return binl2str(core_md5(str2binl(s), s.length * chrsz));} 26 | function hex_hmac_md5(key, data) { return binl2hex(core_hmac_md5(key, data)); } 27 | function b64_hmac_md5(key, data) { return binl2b64(core_hmac_md5(key, data)); } 28 | function str_hmac_md5(key, data) { return binl2str(core_hmac_md5(key, data)); } 29 | 30 | /* 31 | * Perform a simple self-test to see if the VM is working 32 | */ 33 | function md5_vm_test() 34 | { 35 | return hex_md5("abc") == "900150983cd24fb0d6963f7d28e17f72"; 36 | } 37 | 38 | /* 39 | * Calculate the MD5 of an array of little-endian words, and a bit length 40 | */ 41 | function core_md5(x, len) 42 | { 43 | /* append padding */ 44 | x[len >> 5] |= 0x80 << ((len) % 32); 45 | x[(((len + 64) >>> 9) << 4) + 14] = len; 46 | 47 | var a = 1732584193; 48 | var b = -271733879; 49 | var c = -1732584194; 50 | var d = 271733878; 51 | 52 | for(var i = 0; i < x.length; i += 16) 53 | { 54 | var olda = a; 55 | var oldb = b; 56 | var oldc = c; 57 | var oldd = d; 58 | 59 | a = md5_ff(a, b, c, d, x[i+ 0], 7 , -680876936); 60 | d = md5_ff(d, a, b, c, x[i+ 1], 12, -389564586); 61 | c = md5_ff(c, d, a, b, x[i+ 2], 17, 606105819); 62 | b = md5_ff(b, c, d, a, x[i+ 3], 22, -1044525330); 63 | a = md5_ff(a, b, c, d, x[i+ 4], 7 , -176418897); 64 | d = md5_ff(d, a, b, c, x[i+ 5], 12, 1200080426); 65 | c = md5_ff(c, d, a, b, x[i+ 6], 17, -1473231341); 66 | b = md5_ff(b, c, d, a, x[i+ 7], 22, -45705983); 67 | a = md5_ff(a, b, c, d, x[i+ 8], 7 , 1770035416); 68 | d = md5_ff(d, a, b, c, x[i+ 9], 12, -1958414417); 69 | c = md5_ff(c, d, a, b, x[i+10], 17, -42063); 70 | b = md5_ff(b, c, d, a, x[i+11], 22, -1990404162); 71 | a = md5_ff(a, b, c, d, x[i+12], 7 , 1804603682); 72 | d = md5_ff(d, a, b, c, x[i+13], 12, -40341101); 73 | c = md5_ff(c, d, a, b, x[i+14], 17, -1502002290); 74 | b = md5_ff(b, c, d, a, x[i+15], 22, 1236535329); 75 | 76 | a = md5_gg(a, b, c, d, x[i+ 1], 5 , -165796510); 77 | d = md5_gg(d, a, b, c, x[i+ 6], 9 , -1069501632); 78 | c = md5_gg(c, d, a, b, x[i+11], 14, 643717713); 79 | b = md5_gg(b, c, d, a, x[i+ 0], 20, -373897302); 80 | a = md5_gg(a, b, c, d, x[i+ 5], 5 , -701558691); 81 | d = md5_gg(d, a, b, c, x[i+10], 9 , 38016083); 82 | c = md5_gg(c, d, a, b, x[i+15], 14, -660478335); 83 | b = md5_gg(b, c, d, a, x[i+ 4], 20, -405537848); 84 | a = md5_gg(a, b, c, d, x[i+ 9], 5 , 568446438); 85 | d = md5_gg(d, a, b, c, x[i+14], 9 , -1019803690); 86 | c = md5_gg(c, d, a, b, x[i+ 3], 14, -187363961); 87 | b = md5_gg(b, c, d, a, x[i+ 8], 20, 1163531501); 88 | a = md5_gg(a, b, c, d, x[i+13], 5 , -1444681467); 89 | d = md5_gg(d, a, b, c, x[i+ 2], 9 , -51403784); 90 | c = md5_gg(c, d, a, b, x[i+ 7], 14, 1735328473); 91 | b = md5_gg(b, c, d, a, x[i+12], 20, -1926607734); 92 | 93 | a = md5_hh(a, b, c, d, x[i+ 5], 4 , -378558); 94 | d = md5_hh(d, a, b, c, x[i+ 8], 11, -2022574463); 95 | c = md5_hh(c, d, a, b, x[i+11], 16, 1839030562); 96 | b = md5_hh(b, c, d, a, x[i+14], 23, -35309556); 97 | a = md5_hh(a, b, c, d, x[i+ 1], 4 , -1530992060); 98 | d = md5_hh(d, a, b, c, x[i+ 4], 11, 1272893353); 99 | c = md5_hh(c, d, a, b, x[i+ 7], 16, -155497632); 100 | b = md5_hh(b, c, d, a, x[i+10], 23, -1094730640); 101 | a = md5_hh(a, b, c, d, x[i+13], 4 , 681279174); 102 | d = md5_hh(d, a, b, c, x[i+ 0], 11, -358537222); 103 | c = md5_hh(c, d, a, b, x[i+ 3], 16, -722521979); 104 | b = md5_hh(b, c, d, a, x[i+ 6], 23, 76029189); 105 | a = md5_hh(a, b, c, d, x[i+ 9], 4 , -640364487); 106 | d = md5_hh(d, a, b, c, x[i+12], 11, -421815835); 107 | c = md5_hh(c, d, a, b, x[i+15], 16, 530742520); 108 | b = md5_hh(b, c, d, a, x[i+ 2], 23, -995338651); 109 | 110 | a = md5_ii(a, b, c, d, x[i+ 0], 6 , -198630844); 111 | d = md5_ii(d, a, b, c, x[i+ 7], 10, 1126891415); 112 | c = md5_ii(c, d, a, b, x[i+14], 15, -1416354905); 113 | b = md5_ii(b, c, d, a, x[i+ 5], 21, -57434055); 114 | a = md5_ii(a, b, c, d, x[i+12], 6 , 1700485571); 115 | d = md5_ii(d, a, b, c, x[i+ 3], 10, -1894986606); 116 | c = md5_ii(c, d, a, b, x[i+10], 15, -1051523); 117 | b = md5_ii(b, c, d, a, x[i+ 1], 21, -2054922799); 118 | a = md5_ii(a, b, c, d, x[i+ 8], 6 , 1873313359); 119 | d = md5_ii(d, a, b, c, x[i+15], 10, -30611744); 120 | c = md5_ii(c, d, a, b, x[i+ 6], 15, -1560198380); 121 | b = md5_ii(b, c, d, a, x[i+13], 21, 1309151649); 122 | a = md5_ii(a, b, c, d, x[i+ 4], 6 , -145523070); 123 | d = md5_ii(d, a, b, c, x[i+11], 10, -1120210379); 124 | c = md5_ii(c, d, a, b, x[i+ 2], 15, 718787259); 125 | b = md5_ii(b, c, d, a, x[i+ 9], 21, -343485551); 126 | 127 | a = safe_add(a, olda); 128 | b = safe_add(b, oldb); 129 | c = safe_add(c, oldc); 130 | d = safe_add(d, oldd); 131 | } 132 | return Array(a, b, c, d); 133 | 134 | } 135 | 136 | /* 137 | * These functions implement the four basic operations the algorithm uses. 138 | */ 139 | function md5_cmn(q, a, b, x, s, t) 140 | { 141 | return safe_add(bit_rol(safe_add(safe_add(a, q), safe_add(x, t)), s),b); 142 | } 143 | function md5_ff(a, b, c, d, x, s, t) 144 | { 145 | return md5_cmn((b & c) | ((~b) & d), a, b, x, s, t); 146 | } 147 | function md5_gg(a, b, c, d, x, s, t) 148 | { 149 | return md5_cmn((b & d) | (c & (~d)), a, b, x, s, t); 150 | } 151 | function md5_hh(a, b, c, d, x, s, t) 152 | { 153 | return md5_cmn(b ^ c ^ d, a, b, x, s, t); 154 | } 155 | function md5_ii(a, b, c, d, x, s, t) 156 | { 157 | return md5_cmn(c ^ (b | (~d)), a, b, x, s, t); 158 | } 159 | 160 | /* 161 | * Calculate the HMAC-MD5, of a key and some data 162 | */ 163 | function core_hmac_md5(key, data) 164 | { 165 | var bkey = str2binl(key); 166 | if(bkey.length > 16) bkey = core_md5(bkey, key.length * chrsz); 167 | 168 | var ipad = Array(16), opad = Array(16); 169 | for(var i = 0; i < 16; i++) 170 | { 171 | ipad[i] = bkey[i] ^ 0x36363636; 172 | opad[i] = bkey[i] ^ 0x5C5C5C5C; 173 | } 174 | 175 | var hash = core_md5(ipad.concat(str2binl(data)), 512 + data.length * chrsz); 176 | return core_md5(opad.concat(hash), 512 + 128); 177 | } 178 | 179 | /* 180 | * Add integers, wrapping at 2^32. This uses 16-bit operations internally 181 | * to work around bugs in some JS interpreters. 182 | */ 183 | function safe_add(x, y) 184 | { 185 | var lsw = (x & 0xFFFF) + (y & 0xFFFF); 186 | var msw = (x >> 16) + (y >> 16) + (lsw >> 16); 187 | return (msw << 16) | (lsw & 0xFFFF); 188 | } 189 | 190 | /* 191 | * Bitwise rotate a 32-bit number to the left. 192 | */ 193 | function bit_rol(num, cnt) 194 | { 195 | return (num << cnt) | (num >>> (32 - cnt)); 196 | } 197 | 198 | /* 199 | * Convert a string to an array of little-endian words 200 | * If chrsz is ASCII, characters >255 have their hi-byte silently ignored. 201 | */ 202 | function str2binl(str) 203 | { 204 | var bin = Array(); 205 | var mask = (1 << chrsz) - 1; 206 | for(var i = 0; i < str.length * chrsz; i += chrsz) 207 | bin[i>>5] |= (str.charCodeAt(i / chrsz) & mask) << (i%32); 208 | return bin; 209 | } 210 | 211 | /* 212 | * Convert an array of little-endian words to a string 213 | */ 214 | function binl2str(bin) 215 | { 216 | var str = ""; 217 | var mask = (1 << chrsz) - 1; 218 | for(var i = 0; i < bin.length * 32; i += chrsz) 219 | str += String.fromCharCode((bin[i>>5] >>> (i % 32)) & mask); 220 | return str; 221 | } 222 | 223 | /* 224 | * Convert an array of little-endian words to a hex string. 225 | */ 226 | function binl2hex(binarray) 227 | { 228 | var hex_tab = hexcase ? "0123456789ABCDEF" : "0123456789abcdef"; 229 | var str = ""; 230 | for(var i = 0; i < binarray.length * 4; i++) 231 | { 232 | str += hex_tab.charAt((binarray[i>>2] >> ((i%4)*8+4)) & 0xF) + 233 | hex_tab.charAt((binarray[i>>2] >> ((i%4)*8 )) & 0xF); 234 | } 235 | return str; 236 | } 237 | 238 | /* 239 | * Convert an array of little-endian words to a base-64 string 240 | */ 241 | function binl2b64(binarray) 242 | { 243 | var tab = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; 244 | var str = ""; 245 | for(var i = 0; i < binarray.length * 4; i += 3) 246 | { 247 | var triplet = (((binarray[i >> 2] >> 8 * ( i %4)) & 0xFF) << 16) 248 | | (((binarray[i+1 >> 2] >> 8 * ((i+1)%4)) & 0xFF) << 8 ) 249 | | ((binarray[i+2 >> 2] >> 8 * ((i+2)%4)) & 0xFF); 250 | for(var j = 0; j < 4; j++) 251 | { 252 | if(i * 8 + j * 6 > binarray.length * 32) str += b64pad; 253 | else str += tab.charAt((triplet >> 6*(3-j)) & 0x3F); 254 | } 255 | } 256 | return str; 257 | } -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/js/process_ws.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Wouter van Wijk 3 | * 4 | * these functions communication with ws server 5 | * 6 | */ 7 | 8 | /** ****************************************************** 9 | * process results of a (new) currently playing track and any stream title 10 | *********************************************************/ 11 | function processCurrenttrack (data) { 12 | setSongInfo(data) 13 | mopidy.playback.getStreamTitle().then(function (title) { 14 | setStreamTitle(title) 15 | }, console.error) 16 | } 17 | 18 | /** ****************************************************** 19 | * process results of volume 20 | *********************************************************/ 21 | function processVolume (data) { 22 | controls.setVolume(data) 23 | } 24 | 25 | /** ****************************************************** 26 | * process results of mute 27 | *********************************************************/ 28 | function processMute (data) { 29 | controls.setMute(data) 30 | } 31 | 32 | /** ****************************************************** 33 | * process results of a repeat 34 | *********************************************************/ 35 | function processRepeat (data) { 36 | controls.setRepeat(data) 37 | } 38 | 39 | /** ****************************************************** 40 | * process results of random 41 | *********************************************************/ 42 | function processRandom (data) { 43 | controls.setRandom(data) 44 | } 45 | 46 | /** ****************************************************** 47 | * process results of consume 48 | *********************************************************/ 49 | function processConsume (data) { 50 | controls.setConsume(data) 51 | } 52 | 53 | /** ****************************************************** 54 | * process results of single 55 | *********************************************************/ 56 | function processSingle (data) { 57 | controls.setSingle(data) 58 | } 59 | 60 | /** ****************************************************** 61 | * process results of current position 62 | *********************************************************/ 63 | function processCurrentposition (data) { 64 | controls.setPosition(parseInt(data)) 65 | } 66 | 67 | /** ****************************************************** 68 | * process results playstate 69 | *********************************************************/ 70 | function processPlaystate (data) { 71 | if (data === 'playing') { 72 | controls.setPlayState(true) 73 | } else { 74 | controls.setPlayState(false) 75 | } 76 | } 77 | 78 | /** ****************************************************** 79 | * process results of a browse list 80 | *********************************************************/ 81 | function processBrowseDir (resultArr) { 82 | $(BROWSE_TABLE).empty() 83 | if (browseStack.length > 0) { 84 | renderSongLiBackButton(resultArr, BROWSE_TABLE, 'return library.getBrowseDir();') 85 | } 86 | if (!resultArr || resultArr.length === 0) { 87 | showLoading(false) 88 | return 89 | } 90 | uris = [] 91 | var ref, previousRef, nextRef 92 | var uri = resultArr[0].uri 93 | var length = 0 || resultArr.length 94 | customTracklists[BROWSE_TABLE] = [] 95 | var html = '' 96 | var i 97 | 98 | // Render list of tracks 99 | for (i = 0, index = 0; i < resultArr.length; i++) { 100 | if (resultArr[i].type === 'track') { 101 | previousRef = ref || undefined 102 | nextRef = i < resultArr.length - 1 ? resultArr[i + 1] : undefined 103 | ref = resultArr[i] 104 | // TODO: consolidate usage of various arrays for caching URIs, Refs, and Tracks 105 | popupData[ref.uri] = ref 106 | customTracklists[BROWSE_TABLE].push(ref) 107 | uris.push(ref.uri) 108 | 109 | html += renderSongLi(previousRef, ref, nextRef, BROWSE_TABLE, '', BROWSE_TABLE, index, resultArr.length) 110 | 111 | index++ 112 | } else { 113 | html += '
  • ' + 114 | '

    ' + resultArr[i].name + '

  • ' 115 | } 116 | } 117 | 118 | $(BROWSE_TABLE).append(html) 119 | if (browseStack.length > 0) { 120 | window.scrollTo(0, browseStack[browseStack.length - 1].scrollPos || 0) // Restore scroll position 121 | } 122 | 123 | updatePlayIcons(songdata.track.uri, songdata.tlid, controls.getIconForAction()) 124 | 125 | // Look up track details and add album headers 126 | if (uris.length > 0) { 127 | mopidy.library.lookup({'uris': uris}).then(function (resultDict) { 128 | // Break into albums and put in tables 129 | var requiredImages = {} 130 | var track, previousTrack, nextTrack, uri 131 | for (i = 0, index = 0; i < resultArr.length; i++) { 132 | if (resultArr[i].type === 'track') { 133 | previousTrack = track || undefined 134 | if (i < resultArr.length - 1 && resultDict[resultArr[i + 1].uri]) { 135 | nextTrack = resultDict[resultArr[i + 1].uri][0] 136 | } else { 137 | nextTrack = undefined 138 | } 139 | track = resultDict[resultArr[i].uri][0] 140 | popupData[track.uri] = track // Need full track info in popups in order to display albums and artists. 141 | if (uris.length === 1 || (previousTrack && !hasSameAlbum(previousTrack, track) && !hasSameAlbum(track, nextTrack))) { 142 | renderSongLiAlbumInfo(track, BROWSE_TABLE) 143 | } 144 | requiredImages[track.uri] = renderSongLiDivider(previousTrack, track, nextTrack, BROWSE_TABLE)[1] 145 | } 146 | } 147 | showLoading(false) 148 | images.setImages(requiredImages, mopidy, 'small') 149 | }, console.error) 150 | } else { 151 | showLoading(false) 152 | } 153 | } 154 | 155 | /** ****************************************************** 156 | * process results of list of playlists of the user 157 | *********************************************************/ 158 | function processGetPlaylists (resultArr) { 159 | if ((!resultArr) || (resultArr === '')) { 160 | $('#playlistslist').empty() 161 | return 162 | } 163 | var tmp = '' 164 | var favourites = '' 165 | var starred = '' 166 | 167 | for (var i = 0; i < resultArr.length; i++) { 168 | var li_html = '
  • ' 169 | if (isSpotifyStarredPlaylist(resultArr[i])) { 170 | starred = li_html + '★ Spotify Starred Tracks
  • ' + tmp 171 | } else if (isFavouritesPlaylist(resultArr[i])) { 172 | favourites = li_html + '♥ Musicbox Favourites' 173 | } else { 174 | tmp = tmp + li_html + ' ' + resultArr[i].name + '' 175 | } 176 | } 177 | // Prepend the user's Spotify "Starred" playlist and favourites to the results. (like Spotify official client). 178 | tmp = favourites + starred + tmp 179 | $('#playlistslist').html(tmp) 180 | scrollToTracklist() 181 | showLoading(false) 182 | } 183 | 184 | /** ****************************************************** 185 | * process results of a returned list of playlist track refs 186 | *********************************************************/ 187 | function processPlaylistItems (resultDict) { 188 | var playlist = resultDict.playlist 189 | if (!playlist || playlist === '') { 190 | console.log('Playlist', resultDict.uri, 'is invalid') 191 | showLoading(false) 192 | return 193 | } 194 | var playlistUri = resultDict.uri 195 | playlists[playlistUri] = {'uri': playlistUri, 'tracks': []} 196 | if (playlistUri.startsWith('m3u')) { 197 | console.log('Playlist', playlistUri, 'requires tracks lookup') 198 | var trackUris = [] 199 | for (i = 0; i < playlist.tracks.length; i++) { 200 | trackUris.push(playlist.tracks[i].uri) 201 | } 202 | return mopidy.library.lookup({'uris': trackUris}).then(function (tracks) { 203 | for (i = 0; i < trackUris.length; i++) { 204 | var track = tracks[trackUris[i]][0] || playlist.tracks[i] // Fall back to using track Ref if lookup failed. 205 | playlists[playlistUri].tracks.push(track) 206 | } 207 | showLoading(false) 208 | return playlists[playlistUri].tracks 209 | }) 210 | } else { 211 | for (i = 0; i < playlist.tracks.length; i++) { 212 | var track = playlist.tracks[i] 213 | playlists[playlistUri].tracks.push(track) 214 | } 215 | showLoading(false) 216 | return playlists[playlistUri].tracks 217 | } 218 | } 219 | 220 | /** ****************************************************** 221 | * process results of the queue, the current playlist 222 | *********************************************************/ 223 | function processCurrentPlaylist (resultArr) { 224 | currentplaylist = resultArr 225 | resultsToTables(currentplaylist, CURRENT_PLAYLIST_TABLE) 226 | mopidy.playback.getCurrentTlTrack().then(processCurrenttrack, console.error) 227 | updatePlayIcons(songdata.track.uri, songdata.tlid, controls.getIconForAction()) 228 | if (resultArr.length === 0) { 229 | // Last track in queue was deleted, reset UI. 230 | resetSong() 231 | } 232 | } 233 | 234 | /** ****************************************************** 235 | * process results of an artist lookup 236 | *********************************************************/ 237 | function processArtistResults (resultArr) { 238 | if (!resultArr || (resultArr.length === 0)) { 239 | $('#h_artistname').text('Artist not found...') 240 | images.setAlbumImage('', '#artistviewimage, #artistpopupimage', mopidy) 241 | showLoading(false) 242 | return 243 | } 244 | customTracklists[resultArr.uri] = resultArr 245 | 246 | resultsToTables(resultArr, ARTIST_TABLE, resultArr.uri) 247 | var artistname = getArtist(resultArr) 248 | $('#h_artistname, #artistpopupname').html(artistname) 249 | images.setArtistImage(resultArr.uri, resultArr[0].uri, '#artistviewimage, #artistpopupimage', mopidy) 250 | showLoading(false) 251 | } 252 | 253 | /** ****************************************************** 254 | * process results of an album lookup 255 | *********************************************************/ 256 | function processAlbumResults (resultArr) { 257 | if (!resultArr || (resultArr.length === 0)) { 258 | $('#h_albumname').text('Album not found...') 259 | images.setAlbumImage('', '#albumviewcover, #coverpopupimage', mopidy) 260 | showLoading(false) 261 | return 262 | } 263 | customTracklists[resultArr.uri] = resultArr 264 | 265 | albumTracksToTable(resultArr, ALBUM_TABLE, resultArr.uri) 266 | var albumname = getAlbum(resultArr) 267 | var artistname = getArtist(resultArr) 268 | $('#h_albumname').html(albumname) 269 | $('#h_albumartist').html(artistname) 270 | $('#coverpopupalbumname').html(albumname) 271 | $('#coverpopupartist').html(artistname) 272 | images.setAlbumImage(resultArr[0].uri, '#albumviewcover, #coverpopupimage', mopidy) 273 | showLoading(false) 274 | } 275 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/js/images.js: -------------------------------------------------------------------------------- 1 | (function (root, factory) { 2 | if (typeof define === 'function' && define.amd) { 3 | define([], factory) 4 | } else if (typeof module === 'object' && module.exports) { 5 | module.exports = factory() 6 | } else { 7 | root.images = factory() 8 | } 9 | }(this, function () { 10 | 'use strict' 11 | 12 | var API_KEY = 'b6d34c3af91d62ab0ae00ab1b6fa8733' 13 | var API_SECRET = '2c631802c2285d5d5d1502462fe42a2b' 14 | 15 | var images = { 16 | 17 | DEFAULT_ALBUM_URL: 'images/default_cover.png', 18 | DEFAULT_ARTIST_URL: 'images/user_24x32.png', 19 | 20 | lastFM: new LastFM({ 21 | apiKey: API_KEY, 22 | apiSecret: API_SECRET, 23 | cache: new LastFMCache() 24 | }), 25 | 26 | /* Extract artist information from Mopidy track. */ 27 | _getArtistInfo: function (track) { 28 | var artistName = '' 29 | var musicBrainzID = '' 30 | 31 | if (track && track.artists && (track.artists.length > 0)) { 32 | // First look for the artist info in the track 33 | artistName = track.artists[0].name 34 | musicBrainzID = track.artists[0].musicbrainz_id 35 | } 36 | 37 | if ((!artistName || !musicBrainzID) && (track && track.album && track.album.artists && track.album.artists.length > 0)) { 38 | // Fallback to using artist info contained in the track's album 39 | artistName = artistName || track.album.artists[0].name 40 | musicBrainzID = musicBrainzID || track.album.artists[0].musicbrainz_id 41 | } 42 | 43 | return {mbid: musicBrainzID, name: artistName} 44 | }, 45 | 46 | /* Utility function for retrieving artist informaton for the given track from last.fm */ 47 | _getLastFmArtistInfo: function (track) { 48 | var artist = images._getArtistInfo(track) 49 | var artistPromise = $.Deferred() 50 | 51 | if (!(track && (track.musicbrainz_id || (track.name && artist && artist.name)))) { 52 | // Avoid expensive last.fm call if tag information is missing. 53 | return artistPromise.reject('none', 'Not enough tag information available for track to make last.fm call.') 54 | } 55 | 56 | var params = {} 57 | // Only add arguments to parameter object if values are available for them. 58 | if (track.musicbrainz_id) { 59 | params.mbid = track.musicbrainz_id 60 | } 61 | if (track.name && artist.name) { 62 | params.track = track.name 63 | params.artist = artist.name 64 | } 65 | 66 | images.lastFM.track.getInfo(params, {success: function (data) { 67 | artistPromise.resolve(data.track.artist) 68 | }, error: function (code, message) { 69 | artistPromise.reject(code, message) 70 | }}) 71 | 72 | return artistPromise 73 | }, 74 | 75 | /* Utility function for retrieving information for the given track from last.fm. */ 76 | _getLastFmAlbumInfo: function (track) { 77 | var artist = images._getArtistInfo(track) 78 | var albumPromise = $.Deferred() 79 | 80 | if (!(track && track.album && (track.album.musicbrainz_id || (track.album.name && artist && artist.name)))) { 81 | // Avoid expensive last.fm call if tag information is missing. 82 | return albumPromise.reject('none', 'Not enough tag information available for album to make last.fm call.') 83 | } 84 | 85 | var musicBrainzID = track.album.musicbrainz_id 86 | var albumName = track.album.name 87 | var artistName = images._getArtistInfo(track).name 88 | 89 | var params = {} 90 | // Only add arguments to parameter object if values are available for them. 91 | if (musicBrainzID) { 92 | params.mbid = musicBrainzID 93 | } 94 | if (artistName && albumName) { 95 | params.artist = artistName 96 | params.album = albumName 97 | } 98 | 99 | images.lastFM.album.getInfo(params, {success: function (data) { 100 | albumPromise.resolve(data) 101 | }, error: function (code, message) { 102 | albumPromise.reject(code, message) 103 | }}) 104 | 105 | return albumPromise 106 | }, 107 | 108 | /** 109 | * Sets an HTML image element to contain the album cover art of the relevant Mopidy track. 110 | * 111 | * Potential sources for the album image will be interrogated in the following order until 112 | * a suitable image URI is found: 113 | * 1.) mopidy.library.getImages 114 | * 2.) mopidy.models.Track.album.images (DEPRECATED) 115 | * 3.) last.fm using the album MusicBrainz ID 116 | * 4.) last.fm using the album name and track artist name 117 | * 5.) last.fm using the album name and album artist name 118 | * 6.) a default image 119 | * 120 | * @param {string} uri - The URI of the Mopidy track to retrieve the album cover image for. 121 | * @param {string} img_element - The identifier of the HTML image element that will be used 122 | * to render the image. 123 | * @param {object} mopidy - The Mopidy.js object that should be used to communicate with the 124 | * Mopidy server. 125 | * @param {string} size - (Optional) The preferred size of the image. This parameter is only 126 | * used in the last.fm lookups if Mopidy does not provide the image 127 | * directly. Can be one of 'small', 'medium', 'large', 128 | * 'extralarge' (default), or 'mega'. 129 | */ 130 | setAlbumImage: function (uri, img_element, mopidy, size) { 131 | // Set default immediately while we're busy retrieving actual image. 132 | $(img_element).attr('src', images.DEFAULT_ALBUM_URL) 133 | if (!uri) { 134 | return 135 | } 136 | size = size || 'extralarge' 137 | 138 | mopidy.library.getImages({'uris': [uri]}).then(function (imageResults) { 139 | var uri = Object.keys(imageResults)[0] 140 | if (imageResults[uri].length > 0) { 141 | $(img_element).attr('src', imageResults[uri][0].uri) 142 | } else { 143 | // Also check deprecated 'album.images' in case backend does not 144 | // implement mopidy.library.getImages yet... 145 | images._setDeprecatedAlbumImage(uri, img_element, mopidy, size) 146 | } 147 | }) 148 | }, 149 | 150 | setImages: function (img_elements, mopidy, size) { 151 | var uris = [] 152 | // Set default immediately while we're busy retrieving actual image. 153 | Object.keys(img_elements).forEach(function (uri) { 154 | if (img_elements[uri]) { 155 | $(img_elements[uri]).attr('src', images.DEFAULT_ALBUM_URL) 156 | uris.push(uri) 157 | } 158 | }) 159 | size = size || 'extralarge' 160 | mopidy.library.getImages({'uris': uris}).then(function (imageResults) { 161 | Object.keys(imageResults).forEach(function (uri) { 162 | if (imageResults[uri].length > 0) { 163 | $(img_elements[uri]).attr('src', imageResults[uri][0].uri) 164 | } 165 | }) 166 | }) 167 | }, 168 | 169 | // Note that this approach has been deprecated in Mopidy 170 | // TODO: Remove when Mopidy no longer supports retrieving images 171 | // from 'album.images'. 172 | /* Set album image using mopidy.album.images. */ 173 | _setDeprecatedAlbumImage: function (uri, img_element, mopidy, size) { 174 | if (!uri) { 175 | $(img_element).attr('src', images.DEFAULT_ALBUM_URL) 176 | return 177 | } 178 | size = size || 'extralarge' 179 | 180 | mopidy.library.lookup({'uris': [uri]}).then(function (resultDict) { 181 | var uri = Object.keys(resultDict)[0] 182 | var track = resultDict[uri][0] 183 | if (track && track.album && track.album.images && track.album.images.length > 0) { 184 | $(img_element).attr('src', track.album.images[0]) 185 | } else { 186 | // Fallback to last.fm 187 | images._setLastFmAlbumImage(track, img_element, size) 188 | } 189 | }) 190 | }, 191 | 192 | /* Lookup album image on last.fm using the provided Mopidy track. */ 193 | _setLastFmAlbumImage: function (track, img_element, size) { 194 | if (!track || !(track.album || track.artists)) { 195 | $(img_element).attr('src', images.DEFAULT_ALBUM_URL) 196 | return 197 | } 198 | size = size || 'extralarge' 199 | 200 | images._getLastFmAlbumInfo(track).then(function (data) { 201 | for (var i = 0; i < data.album.image.length; i++) { 202 | if (data.album.image[i].size === size) { 203 | $(img_element).attr('src', data.album.image[i]['#text'] || images.DEFAULT_ALBUM_URL) 204 | break 205 | } 206 | } 207 | }, function (code, message) { 208 | $(img_element).attr('src', images.DEFAULT_ALBUM_URL) 209 | console.error('Error getting album info from last.fm (%s: %s)', code, message) 210 | }) 211 | }, 212 | 213 | /** 214 | * Sets an HTML image element to contain the artist image of the relevant Mopidy track. 215 | * 216 | * Potential sources of the artist image will be interrogated in the following order until 217 | * a suitable image URI is found: 218 | * 1.) mopidy.library.getImages 219 | * 2.) last.fm using the artist MusicBrainz ID. If no artist ID is provided, it will be 220 | * looked up on last.fm first using the track and album details. 221 | * 3.) a default image 222 | * 223 | * @param {string} artist_uri - The URI of the Mopidy artist to retrieve the image for. 224 | * @param {string} track_uri - The URI of the Mopidy track that will be used as a fallback 225 | * if the artist URI does not provide any image results. 226 | * @param {string} img_element - The identifier of the HTML image element that will be used 227 | * to render the image. 228 | * @param {object} mopidy - The Mopidy.js object that should be used to communicate with the 229 | * Mopidy server. 230 | * @param {string} size - (Optional) The preferred size of the image. This parameter is only 231 | * used in the last.fm lookups if Mopidy does not provide the image 232 | * directly. Can be one of 'small', 'medium', 'large', 233 | * 'extralarge' (default), or 'mega'. 234 | */ 235 | setArtistImage: function (artist_uri, track_uri, img_element, mopidy, size) { 236 | // Set default immediately while we're busy retrieving actual image. 237 | $(img_element).attr('src', images.DEFAULT_ARTIST_URL) 238 | if (!artist_uri && !track_uri) { 239 | return 240 | } 241 | size = size || 'extralarge' 242 | 243 | if (artist_uri) { 244 | // Use artist as starting point for retrieving image. 245 | mopidy.library.getImages({'uris': [artist_uri]}).then(function (imageResults) { 246 | var uri = Object.keys(imageResults)[0] 247 | if (imageResults[uri].length > 0) { 248 | $(img_element).attr('src', imageResults[uri][0].uri) 249 | } else { 250 | // Fall back to using track as starting point for retrieving image. 251 | images._setArtistImageFromTrack(track_uri, img_element, mopidy, size) 252 | } 253 | }) 254 | } 255 | }, 256 | 257 | /* Set artist image using the supplied Mopidy track URI. */ 258 | _setArtistImageFromTrack: function (uri, img_element, mopidy, size) { 259 | mopidy.library.lookup({'uris': [uri]}).then(function (resultDict) { 260 | var uri = Object.keys(resultDict)[0] 261 | var track = resultDict[uri][0] 262 | var artist = images._getArtistInfo(track) 263 | 264 | if (artist.mbid) { 265 | images._setLastFmArtistImage(artist.mbid, img_element, size) 266 | } else { 267 | // Look up unique MusicBrainz ID for artist first using the available track information 268 | images._getLastFmArtistInfo(track).then(function (artist) { 269 | images._setLastFmArtistImage(artist.mbid, img_element, size) 270 | }, function (code, message) { 271 | $(img_element).attr('src', images.DEFAULT_ARTIST_URL) 272 | console.error('Error retrieving artist info from last.fm. (%s: %s)', code, message) 273 | }) 274 | } 275 | }) 276 | }, 277 | 278 | /* Set artist image using the supplied artist MusicBrainz ID. */ 279 | _setLastFmArtistImage: function (mbid, img_element, size) { 280 | if (!mbid) { 281 | // Avoid expensive last.fm call if tag information is missing. 282 | $(img_element).attr('src', images.DEFAULT_ARTIST_URL) 283 | return 284 | } 285 | size = size || 'extralarge' 286 | 287 | images.lastFM.artist.getInfo({mbid: mbid}, {success: function (data) { 288 | for (var i = 0; i < data.artist.image.length; i++) { 289 | if (data.artist.image[i].size === size) { 290 | $(img_element).attr('src', data.artist.image[i]['#text'] || images.DEFAULT_ARTIST_URL) 291 | break 292 | } 293 | } 294 | }, error: function (code, message) { 295 | $(img_element).attr('src', images.DEFAULT_ARTIST_URL) 296 | console.error('Error retrieving artist info from last.fm. (%s: %s)', code, message) 297 | }}) 298 | } 299 | } 300 | return images 301 | })) 302 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | ********* 2 | Changelog 3 | ********* 4 | 5 | 6 | v3.1.0 (2020-03-22) 7 | =================== 8 | 9 | - Display stream title when available. (PR: #261) 10 | - Fixed loading artist/album info forever when uri is missing. 11 | 12 | v3.0.1 (2019-12-22) 13 | =================== 14 | 15 | - Fix dependency on final release of Mopidy 3.0.0. 16 | 17 | v3.0.0 (2019-12-22) 18 | =================== 19 | 20 | - Depend on final release of Mopidy 3.0.0. 21 | 22 | v3.0.0rc1 (2019-11-18) 23 | ====================== 24 | 25 | - Require Mopidy >= 3.0.0a4. (PR: #268) 26 | - Require Python >= 3.7. (PR: #268) 27 | - Update project setup. (PR: #268) 28 | 29 | v2.6.0 (2019-11-15) 30 | =================== 31 | 32 | - Update for compatibility with upcoming Mopidy v3.0. 33 | - Improved performance when fetching playlist track metadata. 34 | - Fixed support for IPv6 address. 35 | 36 | v2.5.0 (2018-05-22) 37 | =================== 38 | 39 | - Detect additional stream formats (rtmp, rtmps, rtsp). 40 | - Include details of currently selected page in HTML title tag. (Addresses: `#243 `_). 41 | - Prevent excessive calls to the Mopidy server while buffering. (Fixes: `#237 `_). 42 | - Only allow browsing tracks by album if a URI is available for that album. (Fixes: `#250 `_). 43 | 44 | v2.4.0 (2017-03-15) 45 | =================== 46 | 47 | - Now shows server name/IP address and port number at the bottom of the navigation pane. (Addresses: `#67 `_). 48 | - Add ability to insert a track anywhere in the current queue. (Addresses: `#75 `_). 49 | - Add 'Show Track Info' popup which can be activated from any context menu or by clicking on either the 'info' icon next 50 | to the album cover or the track's title text on the 'Now Playing' pane. The popup includes the URI of the track, which 51 | can be inserted into various lists elsewhere in the player. 52 | - Updated icon set for font-awesome 4.7.0. 53 | - Added 'Refresh' button for refreshing libraries. (Addresses: `#75 `_). 54 | - Only show 'Show Album' or 'Show Artist' options in popup menus if URI's for those resources are available. 55 | (Fixes: `#213 `_). 56 | - Now shows correct hostname information in loader popup. (Fixes: `#209 `_). 57 | - Reset 'Now Playing' info when the last track in the tracklist is deleted. Fixes an issue where info of the last song played would be displayed even after the queue had been cleared. 58 | - Now initializes the GUI properly, even if the user is offline or the Mopidy server cannot be reached. 59 | - Fixed `Alarm Clock `_ detection. 60 | - Unplayable files are shown with a different icon in track lists. 61 | - Show all available track information in the 'Show Track Info...' popup. (Fixes: `#227 `_). 62 | - The last scroll position is now always saved when navigating between pages or browsing the library. 63 | (Fixes: `#73 `_, `#93 `_). 64 | - Playlists will now list tracks even if they are no longer available in the library. (Fixes: `#226 `_). 65 | - Fixed an issue on Safari where the first page to load would be too wide to fit on the screen. 66 | - Refreshing album or artist info pages no longer raises an exception. (Fixes: `#230 `_). 67 | 68 | v2.3.0 (2016-05-15) 69 | =================== 70 | 71 | - Enhance build workflow to include style checks and syntax validation for HTML, CSS, and Javascript. 72 | - Now displays album and artist info when browsing tracks. (Addresses: `#99 `_). 73 | - Now remembers which backend was searched previously, and automatically selects that backend as the default search target. 74 | (Addresses: `#130 `_). 75 | - Upgrade Media Progress Timer to version 3.0.0. 76 | - Now retrieves album cover and artist images using MusicBrainzID, if available. 77 | - New configuration parameter ``on_track_click`` can be used to customize the action that is performed when the 78 | user clicks on a track in a list. Valid options are: ``PLAY_NOW``, ``PLAY_NEXT``, ``ADD_THIS_BOTTOM``, 79 | ``ADD_ALL_BOTTOM``, ``PLAY_ALL`` (default), and ``DYNAMIC`` (repeats last action). 80 | (Addresses: `#133 `_). 81 | - Optimized updating of 'now playing' icons in tracklists. 82 | (Addresses: `#184 `_). 83 | - Optimized rendering of large lists of tracks to make UI more responsive. 84 | - Added 'Folder' FontAwesome icon on the Browse pane for browsing the filesystem. 85 | - New icons for 'PLAY' and 'PLAY_ALL' actions. In general, icons with an empty background will perform an action only 86 | on the selected track, while icons with a filled background will apply the action to all tracks in the list. 87 | - Standardize popup dialog layout convention: Sentence fragments have no punctuation, buttons that confirm a 88 | destructive action go on the left. 89 | - Don't create Mopidy models manually. (Fixes: `#172 `_). 90 | - Context menu is now available for all tracks in browse pane. (Fixes: `#126 `_). 91 | - last.fm artist image lookups should now always return the correct image for similarly named artists. 92 | - Ensure that browsed tracks are always added to the queue using the track URI rather than the track's position in the folder. 93 | (Fixes: `#124 `_). 94 | - Fixed an issue where searches would be performed as soon as the user switches to the 'Search' pane, 95 | instead of waiting for the 'Search!' button to be clicked. 96 | - Fixed an issue where the last track in an album was not grouped properly with the rest of the results, and would have 97 | a small divider rendered above it. (Fixes: `#196 `_). 98 | - Replaced JavaScript confirmation prompt on 'Streams' pane with jQuery equivalent. 99 | (Fixes: `#191 `_). 100 | - Clearing the queue should no longer trigger an album cover image lookup. 101 | (Fixes: `#201 `_). 102 | - Update icons and labels for podcast, podcast-gpodder, and podcast-itunes backends. 103 | 104 | v2.2.0 (2016-03-01) 105 | =================== 106 | 107 | - Split vendor-provided JavaScript and CSS libraries into separate folders to make them easier to identify and maintain. 108 | (Addresses: `#143 `_). 109 | - Upgrade Font-Awesome libraries to version 4.5.0. 110 | - Upgrade jQuery libraries to version 1.12.0. 111 | - Upgrade last.fm JavaScript libraries to the latest version available on the GitHub master branch of the repository. 112 | - Mopidy-Musicbox-Webclient is now distributed with a vendor copy of Mopidy.js. (Addresses: `#175 `_). 113 | - Remove unused iScroll libraries and references. 114 | - Remove unused jQuery.Mobile.iScrollView libraries and references. 115 | - Remove unused jQuery.Truncate libraries and references. 116 | - Avoid polling for current track and time changes. (Fixes: `#40 `_). 117 | - Prevent mobile devices from scaling when used in landscape mode. (Fixes: `#157 `_). 118 | - Scrolling now works in full screen mode for Chrome and Safari as well. (Fixes: `#53 `_). 119 | - No longer interferes with changes to Mopidy's volume levels that are triggered externally. (Fixes: `#162 `_). 120 | - Volume slider now works with Mopidy-ALSAMixer again. (Fixes: `#168 `_). 121 | - Now falls back to track artist if album artist is not available for rendering cover art. (Fixes: `#128 `_). 122 | - Replace Javascript prompt with jQuery Mobile equivalent. (Fixes: `#113 `_). 123 | - Fix playlist refresh button. (Fixes: `#173 `_). 124 | - Update save queue functionality to use 'm3u' format. (Fixes: `#177 `_). 125 | 126 | v2.1.1 (2016-02-04) 127 | =================== 128 | 129 | - Replace Javascript for truncating text with more reliable CSS equivalent. (Fixes: `#155 `_). 130 | 131 | v2.1.0 (2016-02-04) 132 | =================== 133 | 134 | - Added optional ``websocket_host`` and ``websocket_port`` config settings. 135 | - Added link to `Alarm Clock `_ (if present). 136 | - Added ability to save Queue as local Playlist. (Addresses: `#106 `_). 137 | - Add support for ``static_dir`` configurations. 138 | (Addresses: `#105 `_). 139 | - Added ability to manually initiate refresh of Playlists. 140 | (Addresses: `#107 `_). 141 | - Now updates the track name when the stream title changes. 142 | - Adding a browsed radio station to the tracklist now also starts playback of the station. 143 | (Addresses: `#98 `_). 144 | - Increase volume slider handle by 30% to make it easier to grab on mobile devices. 145 | - Add application cache manifest file for quicker loads and to allow client devices to detect when local caches should 146 | be invalidated. 147 | - Use standard Mopidy mixer methods to mute / un-mute playback. 148 | - Streams are now saved to the '[Radio Streams].m3u' playlist and are accessible from all clients. 149 | Users with existing streamUris stored as browser cookies will be prompted to convert them to the new m3u backed scheme. 150 | - Mopidy-Musicbox-Webclient now requires at least Mopidy v1.1.0 or greater to be installed. 151 | - Ensure that only the currently playing track is highlighted in the queue. 152 | (Fixes: `#81 `_). 153 | - Fixed slow to start playing from a large tracklist of browsed tracks. 154 | (Fixes: `#85 `_). 155 | - Clean up unused Javascript code. (Fixes: `#100 `_). 156 | - Mopidy 1.1.0 compatibility fixes. (Fixes: `#109 `_, 157 | `#111 `_, 158 | `#121 `_, and 159 | `#123 `_). 160 | - Fix incorrect identification of user's Spotify starred playlist. 161 | (Fixes: `#110 `_). 162 | - Initiating track playback from a folder that contains subfolders now correctly identifies the tracks that should be 163 | played. (Fixes: `#112 `_). 164 | - Adding search results to tracklist now works as expected. 165 | (Fixes: `#49 `_ and 166 | `#135 `_). 167 | - Fix Javascript syntax errors. (Fixes: `#122 `_). 168 | - Fix vertical alignment of playback control buttons in footer. 169 | - Increase width of header so that more text can be rendered in the title bar. 170 | (Fixes: `#144 `_). 171 | - Re-align the menu and search buttons in the title bar. 172 | (Fixes: `#148 `_). 173 | - Use explicit Mopidy.js calling convention. (Fixes: `#79 `_). 174 | - Added event handling for 'muteChanged' event. (Fixes: `#141 `_). 175 | - Remove support for defunct Grooveshark service. 176 | (Fixes: `#120 `_). 177 | 178 | v2.0.0 (2015-03-26) 179 | =================== 180 | 181 | - Pausing a stream will now actually stop it. 182 | - Fix keyboard shortcuts in some browsers. 183 | - Use relative path for script files to fix proxy support. 184 | - Description text for control icons. 185 | - Added consume and single playback modes. 186 | - Changed from a static webclient to a dynamic webapp. 187 | - New musicbox config setting to hide Musicbox specific content. 188 | - Added popup tracks menu to the Browse interface. 189 | - Fixed wrong jQuery version on some pages. 190 | 191 | v1.0.4 (2014-11-24) 192 | =================== 193 | 194 | - Added AudioAddict icon. 195 | - Bugfixes of course. 196 | 197 | v1.0.2 198 | ====== 199 | 200 | - A friendlier welcome with a home page with buttons to the most used functions. 201 | - Converted Radio Stations to Streams, so user can add streams for youtube, spotify, soundcloud, podcasts. 202 | - Enhanced radio/streams interface. 203 | - Search: select service to search. 204 | - Fixed single quote bug. (Fixes: `#39 `_). 205 | - Better handling of coverart. 206 | - Youtube icons added. 207 | - Bugfixes (search, popups, etc.). 208 | 209 | v1.0.1 (2014-09-20) 210 | =================== 211 | 212 | - Small fixes for PyPI distro. 213 | 214 | v1.0.0 (2014-09-20) 215 | =================== 216 | 217 | - Compatible with Mopidy v0.19. 218 | - Made pip installable. 219 | - A lot of fixes. 220 | - Works with mopidy-websettings extension. 221 | 222 | v0.1.0 (2013-07-21) 223 | =================== 224 | 225 | - Compatible with Mopidy 0.14+. 226 | - More ways to add a song to the Queue (play next, add to bottom, etc). 227 | - Better Queue popup. 228 | - Button to clear the Queue. 229 | - A bit more speed. 230 | - Local files show up in search. 231 | - Bugs fixed. 232 | - New instructions in the read me. 233 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/css/webclient.css: -------------------------------------------------------------------------------- 1 | /* 2 | * Mopidy Webclient CSS 3 | * (c) Wouter van Wijk 2012-2017 4 | */ 5 | 6 | /**************************** 7 | * Responsive stuff 8 | * * iphone 3 20em 9 | * landscape 30 10 | * iphone 4 40em 11 | * landscape 60 12 | * ipad landsc 64 13 | * portr 48 14 | ****************************/ 15 | 16 | @media all and (min-width: 961px) { 17 | 18 | /*playlists*/ 19 | .pl-breakpoint.ui-grid-a .ui-block-a { 20 | width: 32.95%; 21 | } 22 | 23 | .pl-breakpoint.ui-grid-a .ui-block-b { 24 | width: 65.4%; 25 | } 26 | 27 | .pl-breakpoint.ui-grid-a .ui-block-a { 28 | clear: left; 29 | } 30 | 31 | .pl-breakpoint.ui-grid-a .ui-block-a { 32 | clear: left; 33 | } 34 | 35 | #playlisttracksdiv { 36 | margin-left: 10px; 37 | } 38 | 39 | .backnav-optional { 40 | display: none; 41 | } 42 | 43 | #playlisttracksdiv { 44 | display: block; 45 | } 46 | 47 | #playlistslistdiv { 48 | display: block; 49 | } 50 | 51 | /*search*/ 52 | .srch-breakpoint.ui-grid-a .ui-block-b { 53 | margin-left: .5em; 54 | } 55 | 56 | .srch-breakpoint.ui-grid-a .ui-block-a, 57 | .srch-breakpoint.ui-grid-a .ui-block-b { 58 | width: 49%; 59 | } 60 | } 61 | 62 | /* phone landscape */ 63 | @media all and (max-width: 960px) { 64 | 65 | /*playlists*/ 66 | .pl-breakpoint .ui-block-a, 67 | .pl-breakpoint .ui-block-b { 68 | width: 100%; 69 | } 70 | 71 | /*search*/ 72 | .srch-breakpoint.ui-grid-a .ui-block-a, 73 | .srch-breakpoint.ui-grid-a .ui-block-b { 74 | width: 100%; 75 | } 76 | 77 | .backnav-optional { 78 | display: block; 79 | } 80 | 81 | #playlisttracksdiv { 82 | display: none; 83 | } 84 | 85 | #playlistslistdiv { 86 | display: block; 87 | } 88 | } 89 | 90 | /***************************** 91 | * Side Panel and Navigation * 92 | *****************************/ 93 | .mainNav .fa { 94 | float: right; 95 | } 96 | 97 | .mainNav .navtxt { 98 | float: left; 99 | } 100 | 101 | .mainNav .navtxt:after { 102 | clear: left; 103 | } 104 | 105 | #contentHeadline a { 106 | color:white; 107 | } 108 | 109 | /**headers and controls**/ 110 | #headermenubtn { 111 | padding-top: 2px; 112 | } 113 | 114 | #headersearchbtn span { 115 | font-size: 15px; 116 | } 117 | 118 | /****************** 119 | * Track Slider * 120 | ******************/ 121 | #trackslider { 122 | display: inline; 123 | width: 100%; 124 | } 125 | 126 | #slidercontainer { 127 | margin-top: 7px; 128 | margin-bottom: 5px; 129 | margin-right: 10px; 130 | } 131 | 132 | .ui-slider-track { 133 | margin-left: 38px; 134 | margin-right: 35px; 135 | } 136 | 137 | /* Increase slider handle by 30%. */ 138 | .ui-slider-track.ui-mini .ui-slider-handle { 139 | height: 22px; 140 | width: 22px; 141 | margin: -12px 0 0 -12px; 142 | } 143 | 144 | .ui-slider-input { 145 | display: none !important; 146 | } 147 | 148 | /************************ 149 | * Volume Slider 150 | ***********************/ 151 | #mutebt { 152 | color: white; 153 | float: left; 154 | margin-left: 8px; 155 | margin-top: 8px; 156 | } 157 | 158 | #volumeslider { 159 | display: inline; 160 | } 161 | 162 | div.hostInfo { 163 | width: 100%; 164 | text-align: center; 165 | overflow: hidden; 166 | text-overflow: ellipsis; 167 | } 168 | 169 | span.hostInfo { 170 | font-weight: normal; 171 | font-size: 0.75em; 172 | overflow: hidden; 173 | text-overflow: ellipsis; 174 | } 175 | 176 | /******************** 177 | * Pages, content * 178 | ********************/ 179 | #page { 180 | background-color: #fff; 181 | } 182 | 183 | #searchartists { 184 | display: none; 185 | } 186 | 187 | #searchalbums { 188 | display: none; 189 | } 190 | 191 | #searchtracks { 192 | display: none; 193 | } 194 | 195 | #currentpane, 196 | #searchpane, 197 | #albumspane, 198 | #artistspane, 199 | #streampane { 200 | display: none; 201 | } 202 | 203 | #artistviewimage, 204 | #albumviewcover { 205 | float: right; 206 | height: 90px; 207 | max-width: 90%; 208 | } 209 | 210 | /*** home ***/ 211 | #homerows div { 212 | text-align: center; 213 | background-color: #2C3E50; 214 | padding: 2px; 215 | padding-top: 20px; 216 | border: 2px solid white; 217 | color: white; 218 | } 219 | 220 | #homerows div i { 221 | font-size: 28px; 222 | } 223 | 224 | .ui-block-a-min { 225 | float: left !important; 226 | width: initial !important; 227 | } 228 | 229 | .ui-block-b-min { 230 | float:right !important; 231 | width: initial !important; 232 | } 233 | 234 | /*************** 235 | * listviews * 236 | ***************/ 237 | .table li a { 238 | color: #555 !important; 239 | font-size: 80% !important; 240 | display: block; 241 | padding: 2px; 242 | padding-right: 4px; 243 | padding-left: 4px; 244 | } 245 | 246 | .table li { 247 | background-color: #F8F8F5; 248 | border: 1px solid #CECECE; 249 | border-bottom: 0; 250 | padding: 0; 251 | } 252 | 253 | .table { 254 | padding: 0; 255 | list-style-type: none; 256 | } 257 | 258 | .table li:last-child { 259 | border-bottom: 1px solid #CECECE; 260 | } 261 | 262 | .info-table { 263 | display: table !important; 264 | } 265 | 266 | .info-table thead { 267 | visibility: collapse; 268 | } 269 | 270 | .info-table th { 271 | border-bottom: none !important; 272 | } 273 | 274 | .info-table tr { 275 | border-bottom: 1px solid #f2f2f2 276 | } 277 | 278 | .info-table td { 279 | color: #555 !important; 280 | padding: 2px; 281 | padding-right: 14px; 282 | padding-left: 14px; 283 | border: none !important; 284 | } 285 | 286 | .info-table td.label { 287 | font-weight: bold; 288 | } 289 | 290 | .info-table td.label-center { 291 | vertical-align: middle; 292 | } 293 | 294 | .info-table input { 295 | color: #555; 296 | border: none; 297 | font-size: 1em; 298 | width: 100%; 299 | } 300 | 301 | .albumdivider h1, .table li h1 { 302 | font-size: 120% !important; 303 | } 304 | 305 | .albumdivider { 306 | background-color: #ddd !important; 307 | } 308 | 309 | .smalldivider { 310 | font-size: 10%; 311 | height: 2px; 312 | background-color: #ddd !important; 313 | } 314 | 315 | #playlistslist li a { 316 | padding: 7px; 317 | } 318 | 319 | #playlistslist, #playlisttracks { 320 | margin: 0 !important; 321 | padding: 0 !important; 322 | } 323 | 324 | .albumli { 325 | padding-left: 5px; 326 | } 327 | 328 | .playlistactive { 329 | background-color: #ccc; 330 | } 331 | 332 | .artistcover { 333 | float: right; 334 | width: 30px; 335 | height: 30px; 336 | margin-right: 3px; 337 | margin-top: 3px; 338 | } 339 | 340 | .currenttrack2 { 341 | background-image: url('../images/icons/play_alt_12x12.png'); 342 | background-repeat: no-repeat; 343 | background-position: 4px 51%; 344 | } 345 | .currenttrack { 346 | background-image: url('../images/icons/play_alt_16x16.png'); 347 | background-repeat: no-repeat; 348 | background-color: #eee; 349 | background-position: 4px 50%; 350 | } 351 | 352 | .currenttrack2 a { 353 | margin-left: 15px; 354 | } 355 | .currenttrack a { 356 | margin-left: 20px; 357 | } 358 | 359 | .song .moreBtn { 360 | float: right; 361 | padding: 15px 18px 12px 22px; 362 | display: inline-block; 363 | line-height: 100%; 364 | } 365 | 366 | .moreBtn i { 367 | color: #ddd; 368 | font-size: initial; 369 | } 370 | 371 | .infoBtn { 372 | top: 0; 373 | width: 90%; 374 | position: absolute; 375 | } 376 | 377 | .infoBtn i { 378 | font-size: 1.33em; 379 | color: #ddd; 380 | background: white; 381 | border-radius: 50%; 382 | height: 1em; 383 | width: 1em; 384 | } 385 | 386 | .backnav { 387 | background-color: #ccc !important; 388 | } 389 | 390 | .refreshLibraryBtnDiv { 391 | display: none; 392 | } 393 | 394 | 395 | /********************** 396 | * Now Playing area * 397 | **********************/ 398 | #nowPlayingFooter { 399 | height: 50px; 400 | line-height: 48px; 401 | text-align: center; 402 | } 403 | 404 | .footerControls { 405 | height: 100%; 406 | font-size: 25px; 407 | padding-right: 10px; 408 | } 409 | 410 | .footerControls div span { 411 | padding-left: 3px; 412 | padding-right: 3px; 413 | height: 100%; 414 | vertical-align: middle; 415 | } 416 | 417 | .footerControls #btplayNowPlaying { 418 | font-size: 42px; 419 | margin-left: 10px; 420 | margin-right: 10px; 421 | } 422 | 423 | /************ 424 | * Popups * 425 | ************/ 426 | #modalname a, #modaldetail a { 427 | color: #444; 428 | background-color:#F8F8F5; 429 | text-decoration: none; 430 | } 431 | 432 | #modalinfo { 433 | position: relative; 434 | display: inline-block; 435 | padding-top: .5em; 436 | } 437 | 438 | .popupArtistLi, 439 | .popupAlbumLi { 440 | display: none 441 | } 442 | 443 | .popupArtistName, 444 | .popupTrackName, 445 | .popupAlbumName, 446 | .popupArtistName { 447 | font-style: oblique; 448 | } 449 | 450 | #artistpopup, 451 | #coverpopup { 452 | max-width: 90%; 453 | background: white; 454 | padding: 5px; 455 | } 456 | 457 | #h_artistname { 458 | margin-bottom: 65px; 459 | margin-top: 10px; 460 | } 461 | 462 | #albumCoverImg, 463 | #coverpopupimage, 464 | #artistpopupimage { 465 | display: block; 466 | margin-left: auto; 467 | margin-right: auto; 468 | margin-bottom: 10px; 469 | max-width: 90%; 470 | max-height: 90%; 471 | } 472 | 473 | /* Override to make buttons more visible in popups.*/ 474 | #popupTracks .ui-btn-up-c, 475 | #popupQueue .ui-btn-up-c { 476 | background: white; 477 | } 478 | 479 | /* Custom icons for popup listviews - see http://demos.jquerymobile.com/1.3.2/#CustomIcons */ 480 | .ui-icon-playAll:after, 481 | .ui-icon-play:after, 482 | .ui-icon-playNext:after, 483 | .ui-icon-insert:after, 484 | .ui-icon-add:after, 485 | .ui-icon-addAll:after, 486 | .ui-icon-remove:after { 487 | color: #34495e; 488 | font-family: 'FontAwesome'; 489 | } 490 | 491 | .ui-icon-playAll:after { 492 | content: '\f144'; 493 | } 494 | 495 | .ui-icon-play:after { 496 | content: '\f01d'; 497 | } 498 | 499 | .ui-icon-playNext:after { 500 | content: '\f149'; 501 | } 502 | 503 | .ui-icon-insert:after { 504 | content: '\f177'; 505 | } 506 | 507 | .ui-icon-add:after { 508 | content: '\f196'; 509 | } 510 | 511 | .ui-icon-addAll:after { 512 | content: '\f0fe'; 513 | } 514 | 515 | .ui-icon-remove:after { 516 | content: '\f00d'; 517 | } 518 | 519 | .ui-icon-playAll, 520 | .ui-icon-play, 521 | .ui-icon-playNext, 522 | .ui-icon-add, 523 | .ui-icon-addAll, 524 | .ui-icon-remove { 525 | background-color: unset; 526 | background-image: none; 527 | font-weight: normal; 528 | } 529 | 530 | .popupDialog, 531 | .popupDialog-full-width { 532 | padding: 10px; 533 | text-align: center; 534 | } 535 | 536 | .popupDialog-full-width { 537 | padding-left: 0; 538 | padding-right: 0; 539 | } 540 | 541 | /*dont hide clear buttons in text input */ 542 | .ui-input-clear-hidden { 543 | display: block !important; 544 | } 545 | 546 | /**************** 547 | * Common use * 548 | ****************/ 549 | #playlistspane { 550 | margin: 0 !important; 551 | } 552 | 553 | a { 554 | text-decoration: none !important; 555 | } 556 | 557 | .pull-right { 558 | float: right; 559 | font-size: 10px; 560 | margin-top: 12px; 561 | } 562 | 563 | .pull-left { 564 | float: left; 565 | font-size: 10px; 566 | margin-top: 12px; 567 | } 568 | 569 | /********************** 570 | * Song information * 571 | **********************/ 572 | .ui-footer { 573 | border: 0px; 574 | } 575 | 576 | #normalFooter { 577 | height: 50px; 578 | line-height: 48px; 579 | text-align: center; 580 | color: white; 581 | } 582 | 583 | #infoname { 584 | overflow: hidden; 585 | white-space: nowrap; 586 | font-weight: bold; 587 | font-size: 14px; 588 | text-overflow: ellipsis; 589 | } 590 | 591 | #infodetail { 592 | overflow: hidden; 593 | font-size: 11px; 594 | white-space: nowrap; 595 | text-overflow: ellipsis; 596 | } 597 | 598 | #infocover { 599 | height: 50px; 600 | width: 50px; 601 | } 602 | 603 | .playicon { 604 | width: 10%; 605 | float: right; 606 | text-align: right; 607 | } 608 | 609 | #btplay { 610 | color: white; 611 | } 612 | 613 | .songinfo { 614 | height: 100%; 615 | width: 90%; 616 | float: left; 617 | } 618 | 619 | .songinfo-text { 620 | text-align: left; 621 | line-height: 22px; 622 | color: white; 623 | overflow: hidden; 624 | padding: 3px; 625 | } 626 | 627 | #nowPlayingpane { 628 | text-align: center; 629 | } 630 | 631 | /*helper*/ 632 | .ui-loader h1 { 633 | color: #efefef; 634 | } 635 | 636 | /*desktop*/ 637 | @media (min-width: 55em) { 638 | /* panel workaround to make it responsive wrap push on wide viewports once open */ 639 | .ui-responsive-panel.ui-page-panel .ui-panel-content-fixed-toolbar-open.ui-panel-content-fixed-toolbar-display-push, 640 | .ui-responsive-panel.ui-page-panel .ui-panel-content-fixed-toolbar-open.ui-panel-content-fixed-toolbar-display-reveal, 641 | .ui-responsive-panel.ui-page-panel .ui-panel-content-wrap-open.ui-panel-content-wrap-display-push, 642 | .ui-responsive-panel.ui-page-panel .ui-panel-content-wrap-open.ui-panel-content-wrap-display-reveal { 643 | margin-right: 17em; 644 | width: auto; 645 | } 646 | 647 | .ui-responsive-panel.ui-page-panel .ui-panel-content-fixed-toolbar-open.ui-panel-content-wrap-display-push.ui-panel-content-fixed-toolbar-position-right, 648 | .ui-responsive-panel.ui-page-panel .ui-panel-content-fixed-toolbar-open.ui-panel-content-wrap-display-reveal.ui-panel-content-fixed-toolbar-position-right, 649 | .ui-responsive-panel.ui-page-panel .ui-panel-content-wrap-open.ui-panel-content-wrap-display-push.ui-panel-content-wrap-position-right, 650 | .ui-responsive-panel.ui-page-panel .ui-panel-content-wrap-open.ui-panel-content-wrap-display-reveal.ui-panel-content-wrap-position-right { 651 | margin: 0 0 0 17em; 652 | } 653 | } 654 | 655 | /*tablets and desktop*/ 656 | @media (min-width: 35em) { 657 | .ui-responsive-panel .ui-panel-dismiss-display-reveal { 658 | display: none; 659 | } 660 | 661 | .popupDialog { 662 | min-width: 320px; 663 | } 664 | } 665 | 666 | /*smartphones*/ 667 | @media (max-width: 35em) { 668 | #nowPlayingpane { 669 | padding: 15px 25px 0 25px; 670 | max-width: 90%; 671 | max-height: 90%; 672 | } 673 | 674 | .nowPlaying-artistInfo { 675 | font-size: 12px; 676 | } 677 | 678 | .nowPlaying-artistInfo h3 { 679 | margin: 0 0 3px 0; 680 | white-space: nowrap; 681 | overflow: hidden; 682 | } 683 | 684 | #albumCoverImg { 685 | max-width: 90%; 686 | max-height: 90%; 687 | margin-bottom: 3px; 688 | } 689 | 690 | #nowPlayingpane #slidercontainer { 691 | margin-left: -5px; 692 | margin-right: -5px; 693 | } 694 | 695 | .nowPlayingControls{ 696 | font-size: 1.3em; 697 | height: 50px; 698 | line-height: 48px; 699 | } 700 | } 701 | 702 | /* disable text selection for mouse swipe */ 703 | body * { 704 | -webkit-user-select: none; 705 | -moz-user-select: none; 706 | -ms-user-select: none; 707 | -o-user-select: none; 708 | user-select: none; 709 | } 710 | 711 | /* but fix for text input (safari certainly needs it)*/ 712 | input[type=text] { 713 | -webkit-user-select: text; 714 | -moz-user-select: text; 715 | -ms-user-select: text; 716 | -o-user-select: text; 717 | user-select: text; 718 | } 719 | -------------------------------------------------------------------------------- /mopidy_musicbox_webclient/static/js/library.js: -------------------------------------------------------------------------------- 1 | (function (root, factory) { 2 | if (typeof define === 'function' && define.amd) { 3 | define([], factory) 4 | } else if (typeof module === 'object' && module.exports) { 5 | module.exports = factory() 6 | } else { 7 | root.library = factory() 8 | } 9 | }(this, function () { 10 | 'use strict' 11 | 12 | var library = { 13 | 14 | /** ******************************* 15 | * Search 16 | *********************************/ 17 | searchPressed: function (key) { 18 | var value = $('#searchinput').val() 19 | switchContent('search') 20 | 21 | if (key === 13) { 22 | library.initSearch() 23 | return false 24 | } 25 | return true 26 | }, 27 | 28 | // init search 29 | initSearch: function () { 30 | var value = $('#searchinput').val() 31 | var searchService = $('#selectSearchService').val() 32 | $.cookie('searchScheme', searchService, { expires: 365 }) 33 | 34 | if ((value.length < 100) && (value.length > 0)) { 35 | showLoading(true) 36 | // hide ios/android keyboard 37 | document.activeElement.blur() 38 | $('input').blur() 39 | 40 | delete customTracklists[URI_SCHEME + ':trackresultscache'] 41 | $('#searchartists').hide() 42 | $('#searchalbums').hide() 43 | $('#searchtracks').hide() 44 | 45 | if (searchService !== 'all') { 46 | mopidy.library.search({'query': {any: [value]}, 'uris': [searchService + ':']}).then(library.processSearchResults, console.error) 47 | } else { 48 | mopidy.getUriSchemes().then(function (schemes) { 49 | var query = {} 50 | var uris = [] 51 | 52 | var regexp = $.map(schemes, function (scheme) { 53 | return '^' + scheme + ':' 54 | }).join('|') 55 | 56 | var match = value.match(regexp) 57 | if (match) { 58 | var scheme = match[0] 59 | query = {uri: [value]} 60 | uris = [scheme] 61 | } else { 62 | query = {any: [value]} 63 | } 64 | mopidy.library.search({'query': query, 'uris': uris}).then(library.processSearchResults, console.error) 65 | }) 66 | } 67 | } 68 | }, 69 | 70 | /** ****************************************************** 71 | * process results of a search 72 | *********************************************************/ 73 | processSearchResults: function (resultArr) { 74 | $(SEARCH_TRACK_TABLE).empty() 75 | $(SEARCH_ARTIST_TABLE).empty() 76 | $(SEARCH_ALBUM_TABLE).empty() 77 | 78 | // Merge results from different backends. 79 | // TODO should of coures have multiple tables 80 | var results = {'tracks': [], 'artists': [], 'albums': []} 81 | var i, j 82 | var emptyResult = true 83 | 84 | for (i = 0; i < resultArr.length; i++) { 85 | if (resultArr[i].tracks) { 86 | for (j = 0; j < resultArr[i].tracks.length; j++) { 87 | results.tracks.push(resultArr[i].tracks[j]) 88 | emptyResult = false 89 | } 90 | } 91 | if (resultArr[i].artists) { 92 | for (j = 0; j < resultArr[i].artists.length; j++) { 93 | results.artists.push(resultArr[i].artists[j]) 94 | emptyResult = false 95 | } 96 | } 97 | if (resultArr[i].albums) { 98 | for (j = 0; j < resultArr[i].albums.length; j++) { 99 | results.albums.push(resultArr[i].albums[j]) 100 | emptyResult = false 101 | } 102 | } 103 | } 104 | 105 | customTracklists[URI_SCHEME + ':trackresultscache'] = results.tracks 106 | 107 | if (emptyResult) { 108 | $('#searchtracks').show() 109 | $(SEARCH_TRACK_TABLE).append( 110 | '
  • No tracks found...

  • ' 111 | ) 112 | toast('No results') 113 | showLoading(false) 114 | return false 115 | } 116 | 117 | if (results.artists.length > 0) { 118 | $('#searchartists').show() 119 | } 120 | 121 | if (results.albums.length > 0) { 122 | $('#searchalbums').show() 123 | } 124 | 125 | if (results.tracks.length > 0) { 126 | $('#searchtracks').show() 127 | } 128 | 129 | // 'Show more' template 130 | var showMoreTemplate = '
  • Show {count} more
  • ' 131 | 132 | // Artist results 133 | var child = '' 134 | var template = '
  • {name}
  • ' 135 | var tokens 136 | 137 | for (i = 0; i < results.artists.length; i++) { 138 | tokens = { 139 | 'id': results.artists[i].uri, 140 | 'name': results.artists[i].name, 141 | 'class': getMediaClass(results.artists[i]) 142 | } 143 | 144 | // Add 'Show all' item after a certain number of hits. 145 | if (i === 4 && results.artists.length > 5) { 146 | child += stringFromTemplate(showMoreTemplate, {'count': results.artists.length - i}) 147 | template = template.replace('
  • ', '
  • ') 148 | } 149 | 150 | child += stringFromTemplate(template, tokens) 151 | } 152 | 153 | // Inject list items, refresh listview and hide superfluous items. 154 | $(SEARCH_ARTIST_TABLE).html(child).listview('refresh').find('.overflow').hide() 155 | 156 | // Album results 157 | child = '' 158 | template = '
  • ' 159 | template += '
    {albumName}
    ' 160 | template += '

    {artistName}

    ' 161 | template += '
  • ' 162 | 163 | for (i = 0; i < results.albums.length; i++) { 164 | tokens = { 165 | 'albumId': results.albums[i].uri, 166 | 'albumName': results.albums[i].name, 167 | 'artistName': '', 168 | 'albumYear': results.albums[i].date, 169 | 'class': getMediaClass(results.albums[i]) 170 | } 171 | if (results.albums[i].artists) { 172 | for (j = 0; j < results.albums[i].artists.length; j++) { 173 | if (results.albums[i].artists[j].name) { 174 | tokens.artistName += results.albums[i].artists[j].name + ' ' 175 | } 176 | } 177 | } 178 | if (tokens.albumYear) { 179 | tokens.artistName += '(' + tokens.albumYear + ')' 180 | } 181 | // Add 'Show all' item after a certain number of hits. 182 | if (i === 4 && results.albums.length > 5) { 183 | child += stringFromTemplate(showMoreTemplate, {'count': results.albums.length - i}) 184 | template = template.replace('
  • ', '
  • ') 185 | } 186 | 187 | child += stringFromTemplate(template, tokens) 188 | } 189 | // Inject list items, refresh listview and hide superfluous items. 190 | $(SEARCH_ALBUM_TABLE).html(child).listview('refresh').find('.overflow').hide() 191 | 192 | // Track results 193 | resultsToTables(results.tracks, SEARCH_TRACK_TABLE, URI_SCHEME + ':trackresultscache') 194 | 195 | showLoading(false) 196 | }, 197 | 198 | /** ******************************* 199 | * Playlists & Browse 200 | *********************************/ 201 | getPlaylists: function () { 202 | // get playlists without tracks 203 | mopidy.playlists.asList().then(processGetPlaylists, console.error) 204 | }, 205 | 206 | getBrowseDir: function (rootdir) { 207 | // get directory to browse 208 | showLoading(true) 209 | if (!rootdir) { 210 | browseStack.pop() 211 | if (browseStack.length > 0) { 212 | rootdir = browseStack[browseStack.length - 1].uri // Navigated one level up 213 | } else { 214 | rootdir = null // Navigated to top of library 215 | } 216 | } else if (browseStack.length === 0 || rootdir !== browseStack[browseStack.length - 1].uri) { 217 | browseStack.push({'uri': rootdir, 'scrollPos': 0}) // Navigated one level down 218 | } 219 | mopidy.library.browse({'uri': rootdir}).then(function (resultArr) { 220 | processBrowseDir(resultArr) 221 | if (rootdir === null) { 222 | $('.refreshLibraryBtnDiv').hide() // Mopidy does not support refreshing list of backends. 223 | } else { 224 | $('.refreshLibraryBtnDiv').show() 225 | $('#refreshLibraryBtn').data('url', rootdir) 226 | $('#refreshLibraryBtn').off('click') 227 | $('#refreshLibraryBtn').one('click', controls.refreshLibrary) 228 | } 229 | }, console.error) 230 | }, 231 | 232 | getCurrentPlaylist: function () { 233 | mopidy.tracklist.getTlTracks().then(processCurrentPlaylist, console.error) 234 | }, 235 | 236 | /** ****************************************************** 237 | * Show tracks of playlist 238 | ********************************************************/ 239 | togglePlaylists: function () { 240 | if ($(window).width() <= 960) { 241 | $('#playlisttracksdiv').toggle(); 242 | // Hide other div 243 | ($('#playlisttracksdiv').is(':visible')) ? $('#playlistslistdiv').hide() : $('#playlistslistdiv').show() 244 | } else { 245 | $('#playlisttracksdiv').show() 246 | $('#playlistslistdiv').show() 247 | } 248 | return true 249 | }, 250 | 251 | /** ********** 252 | * Lookups 253 | ************/ 254 | showTracklist: function (uri) { 255 | showLoading(true) 256 | $(PLAYLIST_TABLE).empty() 257 | library.togglePlaylists() 258 | var tracks = getPlaylistTracks(uri).then(function (tracks) { 259 | resultsToTables(tracks, PLAYLIST_TABLE, uri, 'return library.togglePlaylists();', true) 260 | showLoading(false) 261 | }) 262 | updatePlayIcons(uri, '', controls.getIconForAction()) 263 | $('#playlistslist li a').each(function () { 264 | $(this).removeClass('playlistactive') 265 | if (this.id === uri) { 266 | $(this).addClass('playlistactive') 267 | } 268 | }) 269 | return false 270 | }, 271 | 272 | showArtist: function (nwuri, mopidy) { 273 | $('#popupQueue').popup('close') 274 | $('#popupTracks').popup('close') 275 | $('#controlsmodal').popup('close') 276 | $(ARTIST_TABLE).empty() 277 | 278 | if (!nwuri.length || nwuri === 'undefined') { 279 | return false 280 | } 281 | 282 | // TODO cache 283 | 284 | $('#h_artistname').html('') 285 | showLoading(true) 286 | mopidy.library.lookup({'uris': [nwuri]}).then(function (resultDict) { 287 | var resultArr = resultDict[nwuri] 288 | resultArr.uri = nwuri 289 | processArtistResults(resultArr) 290 | }, console.error) 291 | switchContent('artists', nwuri) 292 | scrollToTop() 293 | return false 294 | }, 295 | 296 | showAlbum: function (uri, mopidy) { 297 | $('#popupQueue').popup('close') 298 | $('#popupTracks').popup('close') 299 | $('#controlsmodal').popup('close') 300 | $(ALBUM_TABLE).empty() 301 | 302 | if (!uri.length || uri === 'undefined') { 303 | return false 304 | } 305 | 306 | // fill from cache 307 | var pl = getTracksFromUri(uri, true) 308 | if (pl.length > 0) { 309 | albumTracksToTable(pl, ALBUM_TABLE, uri) 310 | var albumname = getAlbum(pl) 311 | var artistname = getArtist(pl) 312 | $('#h_albumname').html(albumname) 313 | $('#h_albumartist').html(artistname) 314 | $('#coverpopupalbumname').html(albumname) 315 | $('#coverpopupartist').html(artistname) 316 | showLoading(false) 317 | mopidy.library.lookup({'uris': [uri]}).then(function (resultDict) { 318 | var resultArr = resultDict[uri] 319 | resultArr.uri = uri 320 | processAlbumResults(resultArr) 321 | }, console.error) 322 | } else { 323 | showLoading(true) 324 | $('#h_albumname').html('') 325 | $('#h_albumartist').html('') 326 | mopidy.library.lookup({'uris': [uri]}).then(function (resultDict) { 327 | var resultArr = resultDict[uri] 328 | resultArr.uri = uri 329 | processAlbumResults(resultArr) 330 | }, console.error) 331 | } 332 | // show page 333 | switchContent('albums', uri) 334 | scrollToTop() 335 | return false 336 | }, 337 | 338 | getSearchSchemes: function (searchBlacklist, mopidy) { 339 | var backendName 340 | var searchScheme = $.cookie('searchScheme') 341 | if (searchScheme) { 342 | searchScheme = searchScheme.replace(/"/g, '') 343 | } else { 344 | searchScheme = 'all' 345 | } 346 | $('#selectSearchService').empty() 347 | $('#selectSearchService').append(new Option('All services', 'all')) 348 | mopidy.getUriSchemes().then(function (schemesArray) { 349 | schemesArray = schemesArray.filter(function (el) { 350 | return searchBlacklist.indexOf(el) < 0 351 | }) 352 | for (var i = 0; i < schemesArray.length; i++) { 353 | backendName = getMediaHuman(schemesArray[i]) 354 | if (!backendName) { 355 | // No mapping defined, revert to just showing the scheme with first letter capitalized. 356 | backendName = schemesArray[i].charAt(0).toUpperCase() + schemesArray[i].slice(1) 357 | } 358 | $('#selectSearchService').append(new Option(backendName, schemesArray[i])) 359 | } 360 | $('#selectSearchService').val(searchScheme) 361 | $('#selectSearchService').selectmenu('refresh', true) 362 | }, console.error) 363 | } 364 | } 365 | return library 366 | })) 367 | -------------------------------------------------------------------------------- /tests/js/test_controls.js: -------------------------------------------------------------------------------- 1 | var chai = require('chai') 2 | var expect = chai.expect 3 | var assert = chai.assert 4 | chai.use(require('chai-string')) 5 | chai.use(require('chai-jquery')) 6 | 7 | var sinon = require('sinon') 8 | 9 | var controls = require('../../mopidy_musicbox_webclient/static/js/controls.js') 10 | var DummyTracklist = require('./dummy_tracklist.js') 11 | 12 | describe('controls', function () { 13 | var mopidy 14 | var div_element 15 | var QUEUE_TRACKS = [ // Simulate an existing queue with three tracks loaded. 16 | {uri: 'track:tlTrackMock1'}, // <-- Currently playing track 17 | {uri: 'track:tlTrackMock2'}, 18 | {uri: 'track:tlTrackMock3'} 19 | ] 20 | var NEW_TRACKS = [ // Simulate the user browsing to a folder with three tracks inside it. 21 | {uri: 'track:trackMock1'}, 22 | {uri: 'tunein:track:trackMock2'}, // Stream 23 | {uri: 'track:trackMock3'} 24 | ] 25 | var addSpy 26 | 27 | before(function () { 28 | $(document.body).append('
    ') 29 | $('#popupTracks').popup() // Initialize popup 30 | $(document.body).data('on-track-click', 'PLAY_ALL') // Set default click action 31 | 32 | mopidy = sinon.stub(new Mopidy({callingConvention: 'by-position-or-by-name'})) 33 | 34 | var playback = { 35 | play: sinon.stub(), 36 | stop: sinon.stub() 37 | 38 | } 39 | mopidy.playback = playback 40 | mopidy.playback.stop.returns($.when()) 41 | // Mock the Mopidy tracklist so that we have a predictable state to test against. 42 | mopidy.tracklist = new DummyTracklist() 43 | addSpy = sinon.spy(mopidy.tracklist, 'add') 44 | clearSpy = sinon.spy(mopidy.tracklist, 'clear') 45 | }) 46 | 47 | beforeEach(function () { 48 | mopidy.tracklist.clear() 49 | clearSpy.reset() 50 | mopidy.tracklist.add({uris: getUris(QUEUE_TRACKS)}) 51 | addSpy.reset() 52 | mopidy.playback.play.reset() 53 | }) 54 | 55 | after(function () { 56 | mopidy.tracklist.add.restore() 57 | mopidy.tracklist.clear.restore() 58 | }) 59 | 60 | describe('#playTracks()', function () { 61 | it('PLAY_ALL should clear tracklist first before populating with tracks', function () { 62 | customTracklists[CURRENT_PLAYLIST_TABLE] = NEW_TRACKS 63 | controls.playTracks(PLAY_ALL, mopidy, NEW_TRACKS[0].uri, CURRENT_PLAYLIST_TABLE) 64 | assert(clearSpy.called) 65 | }) 66 | 67 | it('should not clear tracklist for events other than PLAY_ALL', function () { 68 | customTracklists[CURRENT_PLAYLIST_TABLE] = NEW_TRACKS 69 | controls.playTracks(PLAY_NOW, mopidy, NEW_TRACKS[0].uri, CURRENT_PLAYLIST_TABLE) 70 | assert(clearSpy.notCalled) 71 | }) 72 | 73 | it('should raise exception if trackUri parameter is not provided and "track" data attribute is empty', function () { 74 | assert.throw(function () { controls.playTracks('', mopidy) }, Error) 75 | 76 | controls.playTracks(PLAY_ALL, mopidy, NEW_TRACKS[0].uri, CURRENT_PLAYLIST_TABLE) 77 | assert(mopidy.playback.play.calledWithMatch({tlid: mopidy.tracklist._tlTracks[0].tlid})) 78 | }) 79 | 80 | it('should raise exception if playListUri parameter is not provided and "track" data attribute is empty', function () { 81 | assert.throw(function () { controls.playTracks('', mopidy, NEW_TRACKS[0].uri) }, Error) 82 | 83 | controls.playTracks(PLAY_ALL, mopidy, NEW_TRACKS[0].uri, CURRENT_PLAYLIST_TABLE) 84 | assert(mopidy.playback.play.calledWithMatch({tlid: mopidy.tracklist._tlTracks[0].tlid})) 85 | }) 86 | 87 | it('should raise exception if unknown tracklist action is provided', function () { 88 | var getTrackURIsForActionStub = sinon.stub(controls, '_getTrackURIsForAction') // Stub to bypass earlier exception 89 | assert.throw(function () { controls.playTracks('99', mopidy, NEW_TRACKS[0].uri, CURRENT_PLAYLIST_TABLE) }, Error) 90 | getTrackURIsForActionStub.restore() 91 | }) 92 | 93 | it('should use "track" and "list" data attributes as fallback if parameters are not provided', function () { 94 | $('#popupTracks').data('track', 'track:trackMock1') // Simulate 'track:trackMock1' being clicked. 95 | $('#popupTracks').data('list', CURRENT_PLAYLIST_TABLE) 96 | customTracklists[CURRENT_PLAYLIST_TABLE] = NEW_TRACKS 97 | 98 | controls.playTracks(PLAY_ALL, mopidy) 99 | assert(mopidy.playback.play.calledWithMatch({tlid: mopidy.tracklist._tlTracks[0].tlid})) 100 | }) 101 | 102 | it('PLAY_NOW, PLAY_NEXT, and ADD_THIS_BOTTOM should only add one track to the tracklist', function () { 103 | controls.playTracks(PLAY_NOW, mopidy, NEW_TRACKS[0].uri, CURRENT_PLAYLIST_TABLE) 104 | assert(addSpy.calledWithMatch({at_position: 1, uris: [NEW_TRACKS[0].uri]}), 'PLAY_NOW did not add correct track') 105 | 106 | mopidy.tracklist.clear() 107 | mopidy.tracklist.add({uris: getUris(QUEUE_TRACKS)}) 108 | addSpy.reset() 109 | 110 | controls.playTracks(PLAY_NEXT, mopidy, NEW_TRACKS[0].uri, CURRENT_PLAYLIST_TABLE) 111 | assert(addSpy.calledWithMatch({at_position: 1, uris: [NEW_TRACKS[0].uri]}), 'PLAY_NEXT did not add correct track') 112 | 113 | mopidy.tracklist.clear() 114 | mopidy.tracklist.add({uris: getUris(QUEUE_TRACKS)}) 115 | addSpy.reset() 116 | 117 | controls.playTracks(ADD_THIS_BOTTOM, mopidy, NEW_TRACKS[0].uri, CURRENT_PLAYLIST_TABLE) 118 | assert(addSpy.calledWithMatch({uris: [NEW_TRACKS[0].uri]}), 'ADD_THIS_BOTTOM did not add correct track') 119 | }) 120 | 121 | it('PLAY_ALL and ADD_ALL_BOTTOM should add all tracks to tracklist', function () { 122 | controls.playTracks(PLAY_ALL, mopidy, NEW_TRACKS[0].uri) 123 | assert(addSpy.calledWithMatch({uris: getUris(NEW_TRACKS)}), 'PLAY_ALL did not add correct tracks') 124 | addSpy.reset() 125 | 126 | mopidy.tracklist.clear() 127 | mopidy.tracklist.add({uris: getUris(QUEUE_TRACKS)}) 128 | 129 | controls.playTracks(ADD_ALL_BOTTOM, mopidy, NEW_TRACKS[0].uri) 130 | assert(addSpy.calledWithMatch({uris: getUris(NEW_TRACKS)}), 'ADD_ALL_BOTTOM did not add correct tracks') 131 | }) 132 | 133 | it('PLAY_NEXT should insert track after currently playing track by default', function () { 134 | controls.playTracks(PLAY_NOW, mopidy, NEW_TRACKS[0].uri) 135 | assert(addSpy.calledWithMatch({at_position: 1, uris: [NEW_TRACKS[0].uri]}), 'PLAY_NEXT did not insert track at correct position') 136 | }) 137 | 138 | it('PLAY_NEXT should insert track after reference track index, if provided', function () { 139 | controls.playTracks(PLAY_NEXT, mopidy, NEW_TRACKS[0].uri, '', 0) 140 | assert(addSpy.calledWithMatch({at_position: 1, uris: [NEW_TRACKS[0].uri]}), 'PLAY_NEXT did not insert track at correct position') 141 | }) 142 | 143 | it('PLAY_NEXT should insert track even if queue is empty', function () { 144 | mopidy.tracklist.clear() 145 | controls.playTracks(PLAY_NEXT, mopidy, NEW_TRACKS[0].uri) 146 | assert(addSpy.calledWithMatch({at_position: 0, uris: [NEW_TRACKS[0].uri]}), 'PLAY_NEXT did not insert track at correct position') 147 | }) 148 | 149 | it('PLAY_NOW should always insert track at current index', function () { 150 | controls.playTracks(PLAY_NOW, mopidy, NEW_TRACKS[0].uri) 151 | assert(addSpy.calledWithMatch({at_position: 1, uris: [NEW_TRACKS[0].uri]}), 'PLAY_NOW did not insert track at correct position') 152 | addSpy.reset() 153 | 154 | mopidy.tracklist.clear() 155 | 156 | controls.playTracks(PLAY_NOW, mopidy, NEW_TRACKS[0].uri) 157 | assert(addSpy.calledWithMatch({at_position: 0, uris: [NEW_TRACKS[0].uri]}), 'PLAY_NOW did not insert track at correct position') 158 | }) 159 | 160 | it('only PLAY_NOW and PLAY_ALL should trigger playback', function () { 161 | controls.playTracks(PLAY_NOW, mopidy) 162 | assert(mopidy.playback.play.calledWithMatch({tlid: mopidy.tracklist._tlTracks[0].tlid}), 'PLAY_NOW did not start playback of correct track') 163 | mopidy.playback.play.reset() 164 | 165 | mopidy.tracklist.clear() 166 | mopidy.tracklist.add({uris: getUris(QUEUE_TRACKS)}) 167 | 168 | controls.playTracks(PLAY_NEXT, mopidy, NEW_TRACKS[0].uri) 169 | assert.isFalse(mopidy.playback.play.called, 'PLAY_NEXT should not have triggered playback to start') 170 | mopidy.playback.play.reset() 171 | 172 | mopidy.tracklist.clear() 173 | mopidy.tracklist.add({uris: getUris(QUEUE_TRACKS)}) 174 | 175 | controls.playTracks(ADD_THIS_BOTTOM, mopidy, NEW_TRACKS[0].uri) 176 | assert.isFalse(mopidy.playback.play.called, 'ADD_THIS_BOTTOM should not have triggered playback to start') 177 | mopidy.playback.play.reset() 178 | 179 | mopidy.tracklist.clear() 180 | mopidy.tracklist.add({uris: getUris(QUEUE_TRACKS)}) 181 | 182 | controls.playTracks(PLAY_ALL, mopidy, NEW_TRACKS[2].uri) 183 | assert(mopidy.playback.play.calledWithMatch({tlid: mopidy.tracklist._tlTracks[2].tlid}), 'PLAY_ALL did not start playback of correct track') 184 | mopidy.playback.play.reset() 185 | 186 | mopidy.tracklist.clear() 187 | mopidy.tracklist.add({uris: getUris(QUEUE_TRACKS)}) 188 | 189 | controls.playTracks(ADD_ALL_BOTTOM, mopidy, NEW_TRACKS[0].uri) 190 | assert.isFalse(mopidy.playback.play.called, 'ADD_ALL_BOTTOM should not have triggered playback to start') 191 | mopidy.playback.play.reset() 192 | }) 193 | 194 | it('should store last action in cookie if on-track-click mode is set to "DYNAMIC"', function () { 195 | $(document.body).data('on-track-click', 'DYNAMIC') 196 | var cookieStub = sinon.stub($, 'cookie') 197 | controls.playTracks(PLAY_NOW, mopidy) 198 | assert(cookieStub.calledWithMatch('onTrackClick', PLAY_NOW, {expires: 365})) 199 | cookieStub.reset() 200 | 201 | $(document.body).data('on-track-click', 'PLAY_NOW') 202 | controls.playTracks(PLAY_NOW, mopidy) 203 | assert(cookieStub.notCalled) 204 | cookieStub.restore() 205 | }) 206 | }) 207 | 208 | describe('#getAction()', function () { 209 | it('should use default action if none is specified', function () { 210 | window.MOCK_DEFAULT = 99 // Define global variable to test against. 211 | $(document.body).data('on-track-click', 'MOCK_DEFAULT') 212 | assert.equal(controls.getAction(), 99) 213 | }) 214 | 215 | it('should get action from cookie if action is set to "DYNAMIC"', function () { 216 | $(document.body).data('on-track-click', 'DYNAMIC') 217 | var cookieStub = sinon.stub($, 'cookie') 218 | controls.getAction() 219 | assert(cookieStub.called) 220 | cookieStub.restore() 221 | }) 222 | 223 | it('should default to "PLAY_ALL" if no cookie is available for "DYNAMIC"', function () { 224 | $(document.body).data('on-track-click', 'DYNAMIC') 225 | $.removeCookie('onTrackClick') 226 | assert.equal(controls.getAction(), PLAY_ALL) 227 | }) 228 | }) 229 | 230 | describe('#getIconForAction()', function () { 231 | it('should return correct FontAwesome class for each tracklist action', function () { 232 | assert.equal(controls.getIconForAction(PLAY_ALL), 'fa fa-play-circle') 233 | assert.equal(controls.getIconForAction(PLAY_NOW), 'fa fa-play-circle-o') 234 | assert.equal(controls.getIconForAction(PLAY_NEXT), 'fa fa-level-down') 235 | assert.equal(controls.getIconForAction(ADD_THIS_BOTTOM), 'fa fa-plus-square-o') 236 | assert.equal(controls.getIconForAction(ADD_ALL_BOTTOM), 'fa fa-plus-square') 237 | }) 238 | 239 | it('should raise error if unknown tracklist action is provided', function () { 240 | assert.throw(function () { controls.getIconForAction(99) }, Error) 241 | }) 242 | 243 | it('should handle action identifier strings in addition to integers', function () { 244 | assert.equal(controls.getIconForAction('0'), 'fa fa-play-circle-o') 245 | }) 246 | 247 | it('should use default tracklist action if no parameter is provided', function () { 248 | assert.equal(controls.getIconForAction(), 'fa fa-play-circle') 249 | }) 250 | }) 251 | 252 | describe('#_getTrackURIsForAction()', function () { 253 | it('should return just "trackUri" for PLAY_NOW, PLAY_NEXT, and ADD_THIS_BOTTOM', function () { 254 | assert.equal(controls._getTrackURIsForAction(PLAY_NOW, 'mockUri')[0], 'mockUri') 255 | assert.equal(controls._getTrackURIsForAction(PLAY_NEXT, 'mockUri')[0], 'mockUri') 256 | assert.equal(controls._getTrackURIsForAction(ADD_THIS_BOTTOM, 'mockUri')[0], 'mockUri') 257 | }) 258 | 259 | it('should get tracks from "playlistUri" for PLAY_ALL, and ADD_ALL_BOTTOM', function () { 260 | customTracklists[CURRENT_PLAYLIST_TABLE] = NEW_TRACKS 261 | 262 | var tracks = controls._getTrackURIsForAction(PLAY_ALL, NEW_TRACKS[0], CURRENT_PLAYLIST_TABLE) 263 | assert.equal(tracks.length, NEW_TRACKS.length) 264 | for (var i = 0; i < tracks.length; i++) { 265 | assert.equal(tracks[i], NEW_TRACKS[i].uri) 266 | } 267 | }) 268 | 269 | it('should raise error if unknown tracklist action is provided', function () { 270 | assert.throw(function () { controls._getTrackURIsForAction(99) }, Error) 271 | }) 272 | 273 | it('should handle action identifier strings in addition to integers', function () { 274 | assert.equal(controls._getTrackURIsForAction('0', 'mockUri')[0], 'mockUri') 275 | }) 276 | }) 277 | 278 | describe('#insertTrack()', function () { 279 | it('should raise exception if no uri is provided', function () { 280 | assert.throw(function () { controls.insertTrack() }, Error) 281 | }) 282 | 283 | it('should insert track after currently playing track by default', function () { 284 | var tracklistLength = QUEUE_TRACKS.length 285 | var insertUri = NEW_TRACKS[0].uri 286 | 287 | controls.insertTrack(insertUri, mopidy) 288 | 289 | mopidy.tracklist.get_length().then(function (length) { 290 | assert.equal(length, tracklistLength + 1) 291 | }) 292 | 293 | mopidy.tracklist.index().then(function (index) { 294 | mopidy.tracklist.get_tl_tracks().then(function (tlTracks) { 295 | assert.equal(tlTracks[index + 1].track.uri, insertUri) 296 | }) 297 | }) 298 | }) 299 | 300 | it('should insert track at provided index', function () { 301 | var tracklistLength = QUEUE_TRACKS.length 302 | var insertUri = NEW_TRACKS[0].uri 303 | 304 | mopidy.tracklist.get_tl_tracks().then(function (tlTracks) { 305 | controls.insertTrack(insertUri, mopidy, tlTracks[1].tlid) 306 | }) 307 | 308 | mopidy.tracklist.get_length().then(function (length) { 309 | assert.equal(length, tracklistLength + 1) 310 | }) 311 | 312 | mopidy.tracklist.get_tl_tracks().then(function (tlTracks) { 313 | assert.equal(tlTracks[2].track.uri, insertUri) 314 | }) 315 | }) 316 | }) 317 | 318 | describe('#addTrackToBottom()', function () { 319 | it('should raise exception if no uri is provided', function () { 320 | assert.throw(function () { controls.addTrackToBottom() }, Error) 321 | }) 322 | 323 | it('should add track at bottom of tracklist', function () { 324 | var tracklistLength = QUEUE_TRACKS.length 325 | var insertUri = NEW_TRACKS[0].uri 326 | 327 | controls.addTrackToBottom(insertUri, mopidy) 328 | 329 | mopidy.tracklist.get_length().then(function (length) { 330 | assert.equal(length, tracklistLength + 1) 331 | }) 332 | 333 | mopidy.tracklist.get_tl_tracks().then(function (tlTracks) { 334 | assert.equal(tlTracks[tlTracks.length - 1].track.uri, insertUri) 335 | }) 336 | }) 337 | }) 338 | 339 | describe('#removeTrack()', function () { 340 | it('should remove track', function () { 341 | var tracklistLength = QUEUE_TRACKS.length 342 | var deleteUri = QUEUE_TRACKS[1].uri 343 | 344 | mopidy.tracklist.get_tl_tracks().then(function (tlTracks) { 345 | controls.removeTrack(tlTracks[1].tlid, mopidy) 346 | }) 347 | 348 | mopidy.tracklist.get_length().then(function (length) { 349 | assert.equal(length, tracklistLength - 1) 350 | }) 351 | 352 | mopidy.tracklist.get_tl_tracks().then(function (tlTracks) { 353 | var found = false 354 | for (var i = 0; i < tlTracks.length; i++) { 355 | if (tlTracks[i].track.uri === deleteUri) { 356 | found = true 357 | } 358 | } 359 | assert(!found) 360 | }) 361 | }) 362 | }) 363 | 364 | describe('#showInfoPopup()', function () { 365 | var track 366 | var popup = $('
    ') 367 | 368 | before(function () { 369 | track = { 370 | 'uri': QUEUE_TRACKS[0].uri, 371 | 'length': 61000, 372 | 'artists': [ 373 | { 374 | 'uri': 'artistUri1', 375 | 'name': 'nameMock1' 376 | }, { 377 | 'uri': 'artistUri2', 378 | 'name': 'nameMock2' 379 | } 380 | ] 381 | } 382 | var library = { 383 | lookup: sinon.stub() 384 | } 385 | mopidy.library = library 386 | mopidy.library.lookup.returns($.when({'track:tlTrackMock1': [track]})) 387 | 388 | $(document.body).append(popup) 389 | $('#popupShowInfo').data(track, track.uri) // Simulate selection from context menu 390 | $('#popupShowInfo').popup() // Initialize popup 391 | }) 392 | 393 | afterEach(function () { 394 | mopidy.library.lookup.reset() 395 | }) 396 | 397 | it('should default track name', function () { 398 | controls.showInfoPopup('', '#popupShowInfo', mopidy) 399 | assert.equal($('td:contains("Name:")').siblings('td').text(), '(Not available)') 400 | }) 401 | 402 | it('should default album name', function () { 403 | controls.showInfoPopup('', '#popupShowInfo', mopidy) 404 | assert.equal($('td:contains("Album:")').siblings('td').text(), '(Not available)') 405 | }) 406 | 407 | it('should add leading zero if seconds length < 10', function () { 408 | controls.showInfoPopup('', '#popupShowInfo', mopidy) 409 | assert.equal($('td:contains("Length:")').siblings('td').text(), '1:01') 410 | }) 411 | 412 | it('should show plural for artist name', function () { 413 | controls.showInfoPopup('', '#popupShowInfo', mopidy) 414 | assert.isOk($('td:contains("Artists:")')) 415 | }) 416 | }) 417 | }) 418 | --------------------------------------------------------------------------------