├── .gitignore ├── schematic.png ├── src ├── client │ ├── icons │ │ ├── favicon.ico │ │ ├── apple-icon.png │ │ ├── disconnected.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon-96x96.png │ │ ├── ms-icon-70x70.png │ │ ├── apple-icon-57x57.png │ │ ├── apple-icon-60x60.png │ │ ├── apple-icon-72x72.png │ │ ├── apple-icon-76x76.png │ │ ├── default-album-art.png │ │ ├── ms-icon-144x144.png │ │ ├── ms-icon-150x150.png │ │ ├── ms-icon-310x310.png │ │ ├── android-icon-36x36.png │ │ ├── android-icon-48x48.png │ │ ├── android-icon-72x72.png │ │ ├── android-icon-96x96.png │ │ ├── apple-icon-114x114.png │ │ ├── apple-icon-120x120.png │ │ ├── apple-icon-144x144.png │ │ ├── apple-icon-152x152.png │ │ ├── apple-icon-180x180.png │ │ ├── Spotify_Logo_RGB_Green.png │ │ ├── android-icon-144x144.png │ │ ├── android-icon-192x192.png │ │ └── apple-icon-precomposed.png │ ├── app │ │ ├── index.css │ │ ├── intro │ │ │ ├── index.jsx │ │ │ └── index.css │ │ ├── spotify │ │ │ ├── index.jsx │ │ │ └── index.css │ │ ├── selector │ │ │ ├── index.css │ │ │ └── index.jsx │ │ ├── album-art │ │ │ └── index.js │ │ ├── player │ │ │ ├── index.css │ │ │ └── index.jsx │ │ └── index.jsx │ ├── index.jsx │ ├── manifest.json │ ├── index.css │ └── index.html ├── server │ ├── onevent.sh │ ├── gpio.js │ ├── make_playlist.js │ ├── config.json │ ├── button.js │ ├── pin.js │ └── index.js └── childbox │ ├── models │ ├── childbox.skp │ ├── childbox.stl │ ├── chilbox_back.skp │ ├── chilbox_back.stl │ ├── childbox_top.skp │ ├── childbox_top.stl │ ├── childbox_bottom.skp │ └── childbox_bottom.stl │ ├── client │ ├── icons │ │ ├── favicon.ico │ │ ├── apple-icon.png │ │ ├── disconnected.png │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon-96x96.png │ │ ├── ms-icon-70x70.png │ │ ├── ms-icon-144x144.png │ │ ├── ms-icon-150x150.png │ │ ├── ms-icon-310x310.png │ │ ├── android-icon-36x36.png │ │ ├── android-icon-48x48.png │ │ ├── android-icon-72x72.png │ │ ├── android-icon-96x96.png │ │ ├── apple-icon-114x114.png │ │ ├── apple-icon-120x120.png │ │ ├── apple-icon-144x144.png │ │ ├── apple-icon-152x152.png │ │ ├── apple-icon-180x180.png │ │ ├── apple-icon-57x57.png │ │ ├── apple-icon-60x60.png │ │ ├── apple-icon-72x72.png │ │ ├── apple-icon-76x76.png │ │ ├── default-album-art.png │ │ ├── android-icon-144x144.png │ │ ├── android-icon-192x192.png │ │ └── apple-icon-precomposed.png │ └── manifest.json │ └── config.json ├── .idea └── vcs.xml ├── .stylelintrc.js ├── .csslintrc ├── .eslintrc.js ├── package.json ├── webpackfile.js ├── README.md └── LICENSE /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .idea/ 3 | target/ -------------------------------------------------------------------------------- /schematic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/schematic.png -------------------------------------------------------------------------------- /src/client/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/favicon.ico -------------------------------------------------------------------------------- /src/server/onevent.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | curl -s -X POST -d "event=$PLAYER_EVENT" "http://127.0.0.1:80/spotify_event" -------------------------------------------------------------------------------- /src/childbox/models/childbox.skp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/models/childbox.skp -------------------------------------------------------------------------------- /src/childbox/models/childbox.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/models/childbox.stl -------------------------------------------------------------------------------- /src/client/icons/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/apple-icon.png -------------------------------------------------------------------------------- /src/client/icons/disconnected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/disconnected.png -------------------------------------------------------------------------------- /src/client/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/favicon-16x16.png -------------------------------------------------------------------------------- /src/client/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/favicon-32x32.png -------------------------------------------------------------------------------- /src/client/icons/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/favicon-96x96.png -------------------------------------------------------------------------------- /src/client/icons/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/ms-icon-70x70.png -------------------------------------------------------------------------------- /src/childbox/client/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/favicon.ico -------------------------------------------------------------------------------- /src/childbox/models/chilbox_back.skp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/models/chilbox_back.skp -------------------------------------------------------------------------------- /src/childbox/models/chilbox_back.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/models/chilbox_back.stl -------------------------------------------------------------------------------- /src/childbox/models/childbox_top.skp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/models/childbox_top.skp -------------------------------------------------------------------------------- /src/childbox/models/childbox_top.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/models/childbox_top.stl -------------------------------------------------------------------------------- /src/client/icons/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/apple-icon-57x57.png -------------------------------------------------------------------------------- /src/client/icons/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/apple-icon-60x60.png -------------------------------------------------------------------------------- /src/client/icons/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/apple-icon-72x72.png -------------------------------------------------------------------------------- /src/client/icons/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/apple-icon-76x76.png -------------------------------------------------------------------------------- /src/client/icons/default-album-art.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/default-album-art.png -------------------------------------------------------------------------------- /src/client/icons/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/ms-icon-144x144.png -------------------------------------------------------------------------------- /src/client/icons/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/ms-icon-150x150.png -------------------------------------------------------------------------------- /src/client/icons/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/ms-icon-310x310.png -------------------------------------------------------------------------------- /src/childbox/client/icons/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/apple-icon.png -------------------------------------------------------------------------------- /src/childbox/models/childbox_bottom.skp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/models/childbox_bottom.skp -------------------------------------------------------------------------------- /src/childbox/models/childbox_bottom.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/models/childbox_bottom.stl -------------------------------------------------------------------------------- /src/client/icons/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/android-icon-36x36.png -------------------------------------------------------------------------------- /src/client/icons/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/android-icon-48x48.png -------------------------------------------------------------------------------- /src/client/icons/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/android-icon-72x72.png -------------------------------------------------------------------------------- /src/client/icons/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/android-icon-96x96.png -------------------------------------------------------------------------------- /src/client/icons/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/apple-icon-114x114.png -------------------------------------------------------------------------------- /src/client/icons/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/apple-icon-120x120.png -------------------------------------------------------------------------------- /src/client/icons/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/apple-icon-144x144.png -------------------------------------------------------------------------------- /src/client/icons/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/apple-icon-152x152.png -------------------------------------------------------------------------------- /src/client/icons/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/apple-icon-180x180.png -------------------------------------------------------------------------------- /src/childbox/client/icons/disconnected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/disconnected.png -------------------------------------------------------------------------------- /src/childbox/client/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/favicon-16x16.png -------------------------------------------------------------------------------- /src/childbox/client/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/favicon-32x32.png -------------------------------------------------------------------------------- /src/childbox/client/icons/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/favicon-96x96.png -------------------------------------------------------------------------------- /src/childbox/client/icons/ms-icon-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/ms-icon-70x70.png -------------------------------------------------------------------------------- /src/client/icons/Spotify_Logo_RGB_Green.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/Spotify_Logo_RGB_Green.png -------------------------------------------------------------------------------- /src/client/icons/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/android-icon-144x144.png -------------------------------------------------------------------------------- /src/client/icons/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/android-icon-192x192.png -------------------------------------------------------------------------------- /src/client/icons/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/client/icons/apple-icon-precomposed.png -------------------------------------------------------------------------------- /src/childbox/client/icons/ms-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/ms-icon-144x144.png -------------------------------------------------------------------------------- /src/childbox/client/icons/ms-icon-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/ms-icon-150x150.png -------------------------------------------------------------------------------- /src/childbox/client/icons/ms-icon-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/ms-icon-310x310.png -------------------------------------------------------------------------------- /src/childbox/client/icons/android-icon-36x36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/android-icon-36x36.png -------------------------------------------------------------------------------- /src/childbox/client/icons/android-icon-48x48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/android-icon-48x48.png -------------------------------------------------------------------------------- /src/childbox/client/icons/android-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/android-icon-72x72.png -------------------------------------------------------------------------------- /src/childbox/client/icons/android-icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/android-icon-96x96.png -------------------------------------------------------------------------------- /src/childbox/client/icons/apple-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/apple-icon-114x114.png -------------------------------------------------------------------------------- /src/childbox/client/icons/apple-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/apple-icon-120x120.png -------------------------------------------------------------------------------- /src/childbox/client/icons/apple-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/apple-icon-144x144.png -------------------------------------------------------------------------------- /src/childbox/client/icons/apple-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/apple-icon-152x152.png -------------------------------------------------------------------------------- /src/childbox/client/icons/apple-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/apple-icon-180x180.png -------------------------------------------------------------------------------- /src/childbox/client/icons/apple-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/apple-icon-57x57.png -------------------------------------------------------------------------------- /src/childbox/client/icons/apple-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/apple-icon-60x60.png -------------------------------------------------------------------------------- /src/childbox/client/icons/apple-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/apple-icon-72x72.png -------------------------------------------------------------------------------- /src/childbox/client/icons/apple-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/apple-icon-76x76.png -------------------------------------------------------------------------------- /src/childbox/client/icons/default-album-art.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/default-album-art.png -------------------------------------------------------------------------------- /src/childbox/client/icons/android-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/android-icon-144x144.png -------------------------------------------------------------------------------- /src/childbox/client/icons/android-icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/android-icon-192x192.png -------------------------------------------------------------------------------- /src/childbox/client/icons/apple-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bellerofonte/radiobox/HEAD/src/childbox/client/icons/apple-icon-precomposed.png -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /src/client/app/index.css: -------------------------------------------------------------------------------- 1 | 2 | .container { 3 | margin: 0 auto; 4 | padding: 0; 5 | max-width: var(--rbx-max-width); 6 | width: 100vw; 7 | text-align: center; 8 | touch-action: manipulation; 9 | } 10 | -------------------------------------------------------------------------------- /src/client/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './app'; 4 | import './index.css'; 5 | 6 | ReactDOM.render( 7 | , 8 | document.querySelector('#root') 9 | ); 10 | -------------------------------------------------------------------------------- /src/client/app/intro/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import css from './index.css'; 3 | 4 | export default () => { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } -------------------------------------------------------------------------------- /src/childbox/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "stations": [ 3 | ["Deti.fm", "http://ic4.101.ru:8000/a199"], 4 | ["Детские песни", "http://music.myradio.com.ua:8000/kids-songs128.mp3"], 5 | ["Детcкое радио", "http://den.101.ru:4000/det_66_01"] 6 | ], 7 | "autoNext": true, 8 | "include": "./playlist.json" 9 | } 10 | 11 | -------------------------------------------------------------------------------- /src/client/app/intro/index.css: -------------------------------------------------------------------------------- 1 | .intro-container { 2 | height: 100vh; 3 | display: flex; 4 | flex-flow: column nowrap; 5 | align-items: center; 6 | overflow: auto; 7 | padding: 0; 8 | margin: 0; 9 | font-size: var(--rbx-icon-size); 10 | color: #0084D7; 11 | } 12 | 13 | .intro-container::before, 14 | .intro-container::after { 15 | content: ''; 16 | display: block; 17 | flex: 1; 18 | } 19 | -------------------------------------------------------------------------------- /.stylelintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "extends": [ 3 | "stylelint-config-standard", 4 | "stylelint-config-css-modules" 5 | ], 6 | "rules": { 7 | "indentation": 4, 8 | "property-no-vendor-prefix": true, 9 | "shorthand-property-no-redundant-values": null, 10 | "color-hex-length": null, 11 | "block-no-empty": null, 12 | "selector-class-pattern": /^[a-z0-9_\-]+$/ 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /src/client/app/spotify/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import css from './index.css'; 3 | 4 | export default ({turnOff}) => { 5 | return ( 6 |
7 | 8 | 9 | Spotify is currently playing on this device 10 | 11 | 12 | Switch to radio 13 | 14 |
15 | ); 16 | } -------------------------------------------------------------------------------- /.csslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "adjoining-classes": false, 3 | "box-sizing": false, 4 | "box-model": false, 5 | "compatible-vendor-prefixes": false, 6 | "floats": false, 7 | "font-sizes": false, 8 | "gradients": false, 9 | "important": false, 10 | "known-properties": false, 11 | "outline-none": false, 12 | "qualified-headings": false, 13 | "regex-selectors": false, 14 | "shorthand": false, 15 | "text-indent": false, 16 | "unique-headings": false, 17 | "universal-selector": false, 18 | "unqualified-attributes": false, 19 | "zero-units": false, 20 | "vendor-prefix": false, 21 | "duplicate-properties": false 22 | } 23 | -------------------------------------------------------------------------------- /src/client/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "RadioBox", 3 | "short_name": "RadioBox", 4 | "icons": [ 5 | { 6 | "src": "/icons/android-icon-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/icons/apple-icon-180x180.png", 12 | "sizes": "180x180", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/icons/ms-icon-310x310.png", 17 | "sizes": "310x310", 18 | "type": "image/png" 19 | } 20 | ], 21 | "theme_color": "#0084D7", 22 | "background_color": "#0084D7", 23 | "display": "standalone" 24 | } -------------------------------------------------------------------------------- /src/server/gpio.js: -------------------------------------------------------------------------------- 1 | module.exports = (process.env.DEBUG === '1' || process.argv[2] === '--no-gpio') 2 | ? { 3 | on: (ev, cb) => { }, 4 | setup: (pin, dir, cb1, cb2) => { 5 | if (cb2) { 6 | cb2(null); 7 | } else { 8 | cb1(null); 9 | } 10 | }, 11 | read: (pin, cb) => { 12 | cb(null, 1); // just return 1 = GPIO.HIGH 13 | }, 14 | write: (pin, val, cb) => { 15 | cb(null); 16 | }, 17 | destroy: (cb) => { 18 | console.log('\'destroy\' method called'); 19 | cb && cb(); 20 | } 21 | } 22 | : require('rpi-gpio'); 23 | 24 | -------------------------------------------------------------------------------- /src/childbox/client/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ChildBox", 3 | "short_name": "ChildBox", 4 | "icons": [ 5 | { 6 | "src": "/icons/android-icon-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/icons/apple-icon-180x180.png", 12 | "sizes": "180x180", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/icons/ms-icon-310x310.png", 17 | "sizes": "310x310", 18 | "type": "image/png" 19 | } 20 | ], 21 | "theme_color": "#951290", 22 | "background_color": "#951290", 23 | "display": "standalone" 24 | } 25 | -------------------------------------------------------------------------------- /src/client/index.css: -------------------------------------------------------------------------------- 1 | /*@import url(https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.css);*/ 2 | @import url(https://use.fontawesome.com/releases/v5.9.0/css/all.css); 3 | 4 | :root{ 5 | --rbx-max-width: 60vh; 6 | --rbx-icon-size: 20vh; 7 | --rbx-volume-height: 3px; 8 | --rbx-font-size: 4vh; 9 | --rbx-buttons-height: 12vh; 10 | --rbx-player-height: calc(var(--rbx-icon-size) + var(--rbx-volume-height) + var(--rbx-buttons-height)) 11 | } 12 | 13 | html, 14 | body, 15 | :global(#root) { 16 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 17 | font-size: var(--rbx-font-size); 18 | font-weight: 300; 19 | color: #383838; 20 | background: #fff; 21 | margin: 0; 22 | cursor: default; 23 | text-align: center; 24 | } 25 | -------------------------------------------------------------------------------- /src/client/app/spotify/index.css: -------------------------------------------------------------------------------- 1 | .spotify-container { 2 | --rbx-spotify-v-padding: 2vh; 3 | height: calc(100vh - 2 * var(--rbx-spotify-v-padding)); 4 | display: flex; 5 | flex-flow: column nowrap; 6 | align-items: center; 7 | overflow: auto; 8 | padding: var(--rbx-spotify-v-padding) 5vh; 9 | margin: 0; 10 | } 11 | 12 | .spotify-container::before, 13 | .spotify-container::after { 14 | content: ''; 15 | display: block; 16 | flex: 1; 17 | } 18 | 19 | .spotify-logo { 20 | height: var(--rbx-icon-size); 21 | width: 100%; 22 | object-fit: contain; 23 | } 24 | 25 | .spotify-button { 26 | padding: calc(var(--rbx-spotify-v-padding) / 2) var(--rbx-spotify-v-padding); 27 | margin: calc(var(--rbx-spotify-v-padding) / 2) var(--rbx-spotify-v-padding); 28 | background-color: #eeeeee; 29 | border: 1px solid #cccccc; 30 | border-radius: calc(var(--rbx-spotify-v-padding) / 2); 31 | font-weight: bolder; 32 | } -------------------------------------------------------------------------------- /src/client/app/selector/index.css: -------------------------------------------------------------------------------- 1 | .selector-body { 2 | max-width: var(--rbx-max-width); 3 | overflow: hidden; 4 | } 5 | 6 | .selector-list { 7 | width: 100%; 8 | text-align: left; 9 | padding: 0; 10 | margin: 0; 11 | border: 0; 12 | border-spacing: 0; 13 | } 14 | 15 | .selector-list > li { 16 | display: flex; 17 | flex-flow: row nowrap; 18 | align-items: center; 19 | border-top: none; 20 | border-right: none; 21 | border-left: none; 22 | padding: 0; 23 | margin: 0; 24 | border-bottom: 1px solid #ddd; 25 | } 26 | 27 | .selector-list > li:hover { 28 | background-color: #eee; 29 | } 30 | 31 | .selector-logo { 32 | padding: 0.5vh; 33 | font-size: calc(1vh + var(--rbx-font-size)); 34 | margin: 0.25vh 1vh 0.25vh 0.5vh; 35 | border-radius: 1.2vh; 36 | } 37 | 38 | .selector-title { 39 | margin: 0; 40 | padding: 0; 41 | white-space: nowrap; 42 | overflow: hidden; 43 | text-overflow: ellipsis; 44 | flex: 1 1 100%; 45 | } 46 | 47 | .selector-dummy { 48 | /* moves stations list below player header */ 49 | /* -1px is because of border */ 50 | height: calc(var(--rbx-player-height) - 1px); 51 | flex: 1 1 100%; 52 | } -------------------------------------------------------------------------------- /src/server/make_playlist.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const id3 = require('node-id3'); 4 | 5 | const dir_path = (!process.argv[2] ? __dirname : process.argv[2]) + '/'; 6 | 7 | const playlist = []; 8 | 9 | function readDir(title, rel_path, abs_path, list) { 10 | const tracks = []; 11 | fs.readdirSync(abs_path) 12 | .filter(item => item[0] !== '.') 13 | .forEach(item => { 14 | const abs_path2 = abs_path + item; 15 | const rel_path2 = rel_path + item; 16 | const stat = fs.statSync(abs_path2); 17 | if (stat.isFile()) { 18 | addFile(rel_path2, abs_path2, tracks); 19 | } else if (stat.isDirectory()) { 20 | readDir(item, rel_path2 + '/', abs_path2 + '/', list); 21 | } 22 | }); 23 | if (tracks.length > 0) { 24 | list.push({title, tracks}); 25 | } 26 | } 27 | 28 | function addFile(rel_path, abs_path, tracks) { 29 | const tags = id3.read(abs_path); 30 | if (!tags) return; 31 | const {title, genre} = tags; 32 | if (title) { 33 | tracks.push([title, rel_path, genre]); 34 | } else { 35 | const name = path.basename(abs_path, path.extname(abs_path)); 36 | tracks.push([name, rel_path, genre]); 37 | } 38 | } 39 | 40 | readDir('Default', '', dir_path, playlist); 41 | fs.writeFileSync(__dirname + '/playlist.json', JSON.stringify(playlist, null, 4)); 42 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "browser": true, 4 | "commonjs": true, 5 | "es6": true, 6 | "mocha": true 7 | }, 8 | "globals": { 9 | "sinon": true, 10 | "should": true, 11 | "API_DEFAULT_URL": true, 12 | "USE_FAKE_SERVER": true 13 | }, 14 | "extends": [ 15 | "eslint:recommended", 16 | "plugin:react/recommended", 17 | "plugin:no-unused-vars-rest/recommended" 18 | ], 19 | "parserOptions": { 20 | "ecmaFeatures": { 21 | "experimentalObjectRestSpread": true, 22 | "jsx": true 23 | }, 24 | "sourceType": "module" 25 | }, 26 | "plugins": [ 27 | "react", 28 | "no-unused-vars-rest" 29 | ], 30 | "rules": { 31 | "indent": [ 32 | "warn", 33 | 4, {"SwitchCase": 1} 34 | ], 35 | "quotes": [ 36 | "warn", 37 | "single" 38 | ], 39 | "semi": [ 40 | "warn", 41 | "always" 42 | ], 43 | "curly": [ 44 | "warn", 45 | "multi-line" 46 | ], 47 | "padded-blocks": [ 48 | "warn", 49 | "never" 50 | ], 51 | "no-var": "error", 52 | "brace-style": [ 53 | "warn", 54 | "1tbs" 55 | ], 56 | "no-console": 0, 57 | "react/display-name": 0, 58 | "react/prop-types": 0 59 | } 60 | }; 61 | -------------------------------------------------------------------------------- /src/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | RadioBox 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/client/app/album-art/index.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const baseUrl = 'https://ws.audioscrobbler.com/2.0/?format=json&autocorrect=1&api_key=1e36b33338920d554c1a2a99ac5648f6'; 4 | const sizes = {small: 0, medium: 1, large: 2, extralarge: 3, mega: 4}; 5 | 6 | function getImage(json) { 7 | // check if object contains 'image' key 8 | const {image} = (json || {}); 9 | if (!image) { 10 | // No image art found 11 | return Promise.reject(new Error('No results found')) 12 | } 13 | let output = null, idx = -1, max = -1; 14 | // try to find maximum image size 15 | image.forEach((e, i) => { 16 | const sz = sizes[e.size] || -1; 17 | if (e['#text'] && sz > max) { 18 | idx = i; 19 | max = sz; 20 | } 21 | }); 22 | // if found 23 | if (idx > -1) { 24 | output = image[idx]['#text']; 25 | // now, check if target image is last.fm stub image 26 | if (output.endsWith('2a96cbd8b46e442fc41c2b86b821562f.png')) { 27 | output = null; 28 | } 29 | } 30 | return output 31 | ? Promise.resolve(output) 32 | : Promise.reject(new Error('No image found')); 33 | } 34 | 35 | export default function (artist, track) { 36 | const artist_ = encodeURIComponent(artist); 37 | const hasTrack = !!track; 38 | const method = hasTrack ? 'track' : 'artist'; 39 | const rest = hasTrack ? `&track=${encodeURIComponent(track)}` : ''; 40 | const url = baseUrl + `&method=${method}.getinfo&artist=${artist_}${rest}`; 41 | return axios.get(url) 42 | .then(resp => { 43 | const data = resp.data; 44 | if (data.error || !data[method]) { 45 | return Promise.reject(data.message || 'shit happened'); 46 | } 47 | const json = hasTrack ? (data[method].album) : data[method]; 48 | return getImage(json); 49 | }); 50 | }; 51 | 52 | -------------------------------------------------------------------------------- /src/client/app/player/index.css: -------------------------------------------------------------------------------- 1 | .player { 2 | max-width: var(--rbx-max-width); 3 | width: 100vw; 4 | top: 0; 5 | position: fixed; 6 | background: white; 7 | padding: 0; 8 | margin: 0 auto; 9 | border-bottom: solid 1px #ccc; 10 | display: flex; 11 | flex-flow: row wrap; 12 | align-items: stretch; 13 | } 14 | 15 | .player-icon{ 16 | margin: 0; 17 | flex: 0; 18 | min-height: var(--rbx-icon-size); 19 | max-height: var(--rbx-icon-size); 20 | max-width: var(--rbx-icon-size); 21 | min-width: var(--rbx-icon-size); 22 | } 23 | 24 | .player-icon > img { 25 | padding: 0.5vh; 26 | width: calc(100% - 1vh); 27 | height: calc(100% - 1vh); 28 | border-radius: 2.5vh; 29 | } 30 | 31 | .player-title-box { 32 | flex: 1; 33 | display: flex; 34 | flex-flow: column nowrap; 35 | align-items: stretch; 36 | justify-content: center; 37 | max-height: var(--rbx-icon-size); 38 | } 39 | 40 | .player-title { 41 | flex: 0; 42 | text-align: left; 43 | padding: 0.5vh 0 0 1.5vh; 44 | } 45 | 46 | .player-title-small { 47 | font-size: calc(0.85 * var(--rbx-font-size)) !important; 48 | } 49 | 50 | .player-title-smaller { 51 | font-size: calc(0.7 * var(--rbx-font-size)) !important; 52 | } 53 | 54 | .player-title > b { 55 | font-weight: 400; 56 | } 57 | 58 | .player-button-set { 59 | display: table; 60 | width: 100%; 61 | min-width: 100%; 62 | min-height: var(--rbx-buttons-height); 63 | max-height: var(--rbx-buttons-height); 64 | } 65 | 66 | .player-button-set > a { 67 | display: table-cell; 68 | vertical-align: middle; 69 | } 70 | 71 | .player-button-set > a:hover { 72 | color: #444; 73 | } 74 | 75 | .player-btn { 76 | padding: 0; 77 | font-size: calc(1.5vh + var(--rbx-font-size)); 78 | } 79 | 80 | .player-btn-play { 81 | margin: 0; 82 | padding: 0 1vh; 83 | font-size: calc(3.5vh + var(--rbx-font-size)); 84 | } 85 | 86 | .player-volume { 87 | min-height: var(--rbx-volume-height); 88 | max-height: var(--rbx-volume-height); 89 | min-width: 100%; 90 | } 91 | -------------------------------------------------------------------------------- /src/server/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "stations": [ 3 | ["Eagle Classic Rock", "http://listen.livestreamingservice.com/181-eagle_128k.mp3", "rock"], 4 | ["The Office", "http://listen.livestreamingservice.com/181-office_128k.mp3", "pop"], 5 | ["Classic Rock HD", "http://144.217.158.59:5310/live/", "rock"], 6 | ["Chilled Out", "http://listen.livestreamingservice.com/181-chilled_128k.mp3", "lounge"], 7 | ["Smooth AC", "http://listen.livestreamingservice.com/181-smoothac_128k.mp3", "lounge"], 8 | ["Chillout Lounge", "http://strm112.1.fm/chilloutlounge_mobile_mp3", "lounge"], 9 | ["Easy Hits Florida", "http://airspectrum.cdnstream1.com:8116/1649_192", "pop"], 10 | ["Audiophile Lounge", "http://8.38.78.173:8226/stream", "lounge"], 11 | ["Ledjam", "http://ledjamradio.ice.infomaniak.ch/ledjamradio.mp3", "pop"], 12 | ["Europa Plus", "http://ep256.streamr.ru", "pop"], 13 | ["Love Radio", "http://stream.loveradio.ru/12_love_56?type=.aac&UID=536B1FC43467FA575893AFDE46F957F2", "pop"], 14 | ["Hit FM", "https://rmgradio.gcdn.co/hit_m.aac", "pop"], 15 | ["Record Chill-Out", "http://air.radiorecord.ru:8102/chil_320", "lounge"], 16 | ["Today's Office Mix", "http://144.217.253.136:8470/mp3", "pop"], 17 | ["Party 181", "http://listen.livestreamingservice.com/181-party_128k.mp3", "pop"], 18 | ["Radio Imagine", "http://broad1.imagineradio.ru:8000/light_stream128.mp3", "rock"] 19 | ], 20 | "pins": { 21 | "buttonPlay": 11, 22 | "buttonVolUp": 13, 23 | "buttonVolDown": 15, 24 | "ledBlue": 16, 25 | "smooth": 18, 26 | "ledWhite": 22, 27 | "mute": 7 28 | }, 29 | "timeouts": { 30 | "fast": 100, 31 | "slow": 2800, 32 | "longPress": 1200, 33 | "volume": 200 34 | }, 35 | "volume": { 36 | "max": 25, 37 | "def": 6, 38 | "delta": 1 39 | }, 40 | "autoPlay": false, 41 | "spotify": { 42 | "enabled": true, 43 | "backend": "alsa", 44 | "device": "hw:0,0", 45 | "bitrate": 320, 46 | "volume": 30, 47 | "restartTimeout": 3000 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "group": "com.radiobox", 3 | "title": "radiobox", 4 | "private": true, 5 | "scripts": { 6 | "prod": "webpack --mode=production --config webpackfile.js -p --env.devtool=nosources-source-map", 7 | "dev": "webpack-dev-server --host 0.0.0.0 --mode=development --env.dist=debug --config webpackfile.js --hot" 8 | }, 9 | "devDependencies": { 10 | "autoprefixer": "^9.6.0", 11 | "babel-core": "^6.26.3", 12 | "babel-loader": "^7.0.0", 13 | "babel-preset-es2015": "^6.24.1", 14 | "babel-preset-react": "^6.24.1", 15 | "babel-preset-stage-2": "^6.24.1", 16 | "copy-webpack-plugin": "^4.6.0", 17 | "css-loader": "^1.0.1", 18 | "eslint": "^5.16.0", 19 | "eslint-loader": "^2.1.2", 20 | "eslint-plugin-react": "^7.13.0", 21 | "extract-loader": "^3.0.0", 22 | "file-loader": "^2.0.0", 23 | "html-loader": "^0.5.5", 24 | "jshint-stylish": "^0.4.0", 25 | "mocha": "^5.2.0", 26 | "postcss-loader": "^3.0.0", 27 | "react-hot-loader": "^4.11.0", 28 | "should": "^11.2.1", 29 | "sinon": "^1.17.7", 30 | "style-loader": "^0.13.2", 31 | "stylelint": "^10.1.0", 32 | "stylelint-config-css-modules": "^1.4.0", 33 | "stylelint-config-standard": "^18.3.0", 34 | "webpack": "^4.34.0", 35 | "webpack-cli": "^3.3.4", 36 | "webpack-dev-server": "^3.11.2" 37 | }, 38 | "dependencies": { 39 | "axios": "^0.21.1", 40 | "core-js": "^2.6.9", 41 | "es6-promise": "^4.2.8", 42 | "express": "latest", 43 | "font-awesome": "latest", 44 | "fs": "^0.0.1-security", 45 | "http": "latest", 46 | "lodash": "^4.17.21", 47 | "messageformat": "^2.2.1", 48 | "moment": "^2.24.0", 49 | "mpc-js": "latest", 50 | "node-id3": "^0.1.7", 51 | "npm": "^6.14.12", 52 | "path": "^0.12.7", 53 | "react": "^15.4.1", 54 | "react-dom": "^15.4.1", 55 | "requirejs": "2.1.14", 56 | "rpi-gpio": "latest", 57 | "socket.io": "^2.4.1", 58 | "socket.io-client": "^2.2.0" 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/client/app/player/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import css from './index.css'; 3 | 4 | function getTitleStyle(title) { 5 | const ex = (title.length > 35) 6 | ? css.playerTitleSmaller 7 | : (title.length > 25 ? css.playerTitleSmall : ''); 8 | return [css.playerTitle, ex].join(' ') 9 | } 10 | 11 | export default ({artist, track, icon, volume, status, playerChange, playerSetVolume, playerPlayPause}) => { 12 | const vStyle = { 13 | width: `${volume || 100}%`, 14 | backgroundColor: (volume ? 'black' : 'transparent'), 15 | height: '100%' 16 | }; 17 | return (
18 |
19 | 20 |
21 |
22 |
23 | {artist} 24 |
25 |
26 | {track} 27 |
28 |
29 |
30 | playerSetVolume(-1)}> 31 | 32 | 33 | playerChange(-1)}> 34 | 35 | 36 | 37 | 38 | 39 | playerChange(1)}> 40 | 41 | 42 | playerSetVolume(1)}> 43 | 44 | 45 |
46 |
47 |
48 |
49 |
); 50 | }; 51 | 52 | const getStatusIcon = (status) => { 53 | switch (status) { 54 | case 'play': return 'fa-pause'; 55 | case 'pause': return 'fa-play'; 56 | case 'waiting': return 'fa-compact-disc fa-spin'; 57 | default: return 'fa-exclamation-circle'; 58 | } 59 | }; 60 | 61 | -------------------------------------------------------------------------------- /src/server/button.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events'); 2 | const {Reader} = require('./pin'); 3 | 4 | module.exports = class extends EventEmitter { 5 | constructor(pin, longPressTimeout, longPressTimerInterval = 0) { 6 | super(); 7 | this.pin = new Reader(pin); 8 | this.longPressHandler = null; 9 | this.longPressTimer = 0; 10 | this.updateBlinking = null; 11 | this.pin.on('changed', value => { 12 | if (value) { 13 | // button is pressed 14 | // reset old timeout if it exists 15 | if (this.longPressHandler) 16 | clearTimeout(this.longPressHandler); 17 | // setup new timeout for long-press event 18 | if (longPressTimeout > 0) { 19 | this.longPressHandler = setTimeout(() => { 20 | if (this.longPressHandler) { 21 | clearTimeout(this.longPressHandler); 22 | this.longPressHandler = null; 23 | } 24 | if (longPressTimerInterval > 0) { 25 | if (this.longPressTimer) { 26 | clearInterval(this.longPressTimer); 27 | } 28 | this.longPressTimer = setInterval(() => { 29 | this.emit('hold', this); 30 | }, longPressTimerInterval); 31 | this.emit('hold', this); 32 | } else { 33 | this.emit('long', this); 34 | } 35 | }, longPressTimeout); 36 | } 37 | // now - call 'updateBlinking' callback 38 | if (this.updateBlinking) 39 | this.updateBlinking(); 40 | this.emit('down', this); 41 | } else { 42 | // reset any timer or timeout 43 | if (this.longPressHandler) { 44 | clearTimeout(this.longPressHandler); 45 | this.longPressHandler = null; 46 | } 47 | if (this.longPressTimer) { 48 | clearInterval(this.longPressTimer); 49 | this.longPressTimer = null; 50 | } 51 | // now - call 'updateBlinking' callback 52 | if (this.updateBlinking) 53 | this.updateBlinking(); 54 | this.emit('up', this); 55 | } 56 | }); 57 | } 58 | 59 | isPressed() { 60 | return this.pin.get() === 1; 61 | } 62 | 63 | setup(updateBlinking) { 64 | this.updateBlinking = updateBlinking; 65 | return this.pin.setup(); 66 | } 67 | }; -------------------------------------------------------------------------------- /src/server/pin.js: -------------------------------------------------------------------------------- 1 | const EventEmitter = require('events'); 2 | const GPIO = require('./gpio'); 3 | 4 | const Reader = class extends EventEmitter { 5 | constructor(pin) { 6 | super(); 7 | this.pin = pin; 8 | this.value = 0; 9 | } 10 | 11 | get() { 12 | return this.value; 13 | } 14 | 15 | setup() { 16 | if (!this.pin) { 17 | return Promise.resolve(); 18 | } 19 | return new Promise((resolve, reject) => { 20 | GPIO.on('change', (channel, val) => { 21 | if (channel === this.pin) { 22 | const value_ = +val; 23 | if (value_ !== this.value) { 24 | this.value = value_; 25 | this.emit('changed', this.value); 26 | } 27 | } 28 | }); 29 | GPIO.setup(this.pin, GPIO.DIR_IN, GPIO.EDGE_BOTH, 30 | (err) => { err ? reject(err) : resolve(); }); // off at startup 31 | }); 32 | } 33 | }; 34 | 35 | const Writer = class { 36 | constructor(pin) { 37 | this.pin = pin; 38 | this.value = NaN; 39 | } 40 | 41 | setup() { 42 | if (!this.pin) { 43 | return Promise.resolve(); 44 | } 45 | return new Promise((resolve, reject) => { 46 | GPIO.setup(this.pin, GPIO.DIR_LOW, err => { 47 | if (err) reject(err); 48 | else { 49 | this.value = 0; 50 | resolve(); 51 | } 52 | }); // off at startup 53 | }); 54 | } 55 | 56 | set(val) { 57 | const value = +val; 58 | if (value !== 0 && value !== 1) { 59 | return Promise.reject(new Error('Wrong value: neither 0 nor 1')); 60 | } 61 | if (value === this.value) { 62 | return Promise.resolve(value); // do nothing 63 | } 64 | if (!this.pin) { 65 | // in case of undefined pin - just set target value 66 | this.value = value; 67 | return Promise.resolve(value); 68 | } 69 | return new Promise((resolve, reject) => { 70 | GPIO.write(this.pin, value, err => { 71 | if (err) reject(err); 72 | else { 73 | this.value = value; 74 | resolve(value); 75 | } 76 | }); 77 | }); 78 | } 79 | 80 | get() { 81 | return this.value; 82 | } 83 | 84 | toggle() { 85 | return this.set(1 - this.value); 86 | } 87 | 88 | getStr() { 89 | switch (this.value) { 90 | case 0: return 'Off'; 91 | case 1: return 'On'; 92 | default: return 'Broken'; 93 | } 94 | } 95 | }; 96 | 97 | module.exports = { Reader, Writer }; -------------------------------------------------------------------------------- /webpackfile.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | const CopyWebpackPlugin = require('copy-webpack-plugin'); 4 | 5 | module.exports = (env = {}) => { 6 | const srcDir = path.join(__dirname, 'src/client'); 7 | const distDir = env.dist ? path.join(process.cwd(), env.dist) : path.join(__dirname, 'target/client'); 8 | 9 | const config = { 10 | context: srcDir, 11 | module: { 12 | rules: [{ 13 | test: /\.jsx?$/, 14 | exclude: /node_modules/, 15 | use: [{ 16 | loader: 'babel-loader', 17 | options: { 18 | presets: ['es2015', 'stage-2', 'react'] 19 | } 20 | }] 21 | },{ 22 | test: /\.css$/, 23 | loader: 'style-loader!css-loader?modules&camelCase' 24 | },{ 25 | test: /\.html$/, 26 | loader: 'file-loader?name=[path][name].[ext]!extract-loader!html-loader' 27 | }] 28 | }, 29 | plugins: [ 30 | new webpack.LoaderOptionsPlugin({ 31 | test: /\.css$/, 32 | options: { 33 | postcss: [ 34 | require('stylelint'), 35 | require('autoprefixer')({ browsers: ['defaults'] }) 36 | ] 37 | } 38 | }), 39 | new webpack.DefinePlugin({ 40 | 'RADIOBOX_DEBUG': JSON.stringify(process.env.DEBUG || ''), 41 | 'RADIOBOX_HOST': JSON.stringify(process.env.HOST || '') 42 | }), 43 | new webpack.ProvidePlugin({ 44 | URL: 'url-parse' 45 | }), 46 | new CopyWebpackPlugin([ 47 | {from:'icons',to:'icons'} 48 | ]), 49 | new CopyWebpackPlugin([ 50 | {from:'../server',to:'../'} 51 | ]), 52 | new CopyWebpackPlugin([ 53 | {from:'manifest.json',to:'manifest.json'} 54 | ]) 55 | ], 56 | output: { 57 | filename: 'index.js', 58 | path: distDir, 59 | devtoolModuleFilenameTemplate: function(info) { 60 | // HACK use path.relative twice 61 | const filename = path.relative(__dirname, info.absoluteResourcePath); 62 | return path.relative(__dirname, filename); 63 | } 64 | }, 65 | resolve: { 66 | extensions: ['.js', '.jsx'] 67 | }, 68 | performance: { 69 | hints: false 70 | }, 71 | devServer: { 72 | contentBase: distDir, 73 | noInfo: true, 74 | port: 8000 75 | }, 76 | // TODO https://github.com/webpack/webpack/issues/2145 77 | devtool: env.devtool || 'inline-source-map' 78 | }; 79 | 80 | if (env.karma) { 81 | // do nothing 82 | } else { 83 | Object.assign(config, { 84 | entry: ['./index.jsx', './index.html'] 85 | }); 86 | } 87 | 88 | return config; 89 | }; 90 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RadioBox 2 | 3 | This stuff turns your Raspberry Pi into internet-radio box. 4 | It wraps MPD and provides Web-UI for controlling it's playback and volume. 5 | 6 | Since commit `#28` RadioBox also uses 8 GPIO pins to control playback and indicate current player state. 7 | The pins are: 8 | * 3 Input pins (for button switches) 9 | * 2 Output pins for LEDs 10 | * 1 Output pin for smooth LED blinking 11 | * +3.3V 12 | * Ground 13 | 14 | The `schematic.png` shows how it is supposed to be implemented. 15 | ![schematic.png](./schematic.png) 16 | 17 | Input and output pins are to be specified in `config.json` file. 18 | 19 | #### Building 20 | 21 | Checkout from Github repository to your PC/Mac and run 22 | ``` 23 | npm install 24 | npm run prod 25 | ``` 26 | By default, output directory is `./target`. You can change it in `webpackfile.js` 27 | 28 | #### Installing 29 | 30 | *I mean that Nodejs and MPD have been installed and configured already.* 31 | 32 | First, copy files from output directory anywhere to your Raspberry Pi. Then install needed Nodejs modules 33 | ``` 34 | npm install http express socket.io mpc-js [rpi-gpio] [fs path node-id3] 35 | ``` 36 | Install `rpi-gpio` only if you require LEDs or buttons. 37 | Install `fs path node-id3` only if you are going to deal with music files. 38 | These modules are required for `make_playlist.js`. 39 | 40 | #### Configuring 41 | 42 | The `config.json` provides all required settings as a JSON object. 43 | The `stations` field is array of `[title, url, genre]` pairs. 44 | The `include` filed is a path to external playlist file. External playlist could be created by running `make_playlist.js` 45 | The `pins` field contains pin numbers (*not names!*) used in this project. 46 | The `timeouts` field describes, how fast LEDs will blink and how button long press will be handled. 47 | The `volume` field describes, what the maximum and default comfortable volume levels are and how fast change volume via buttons/UI. 48 | The `autoPlay` field tells if MPD should start play on boot or not. 49 | 50 | Every field is optional. RadioBox will combine items from `stations` field and files from external playlist described in `include`. 51 | The only condition to run RadioBox - that resulting playlist has at least one entry. 52 | All other fields can be missed, empty of partially filled (in case if you do not need full functionality). 53 | 54 | #### Running 55 | ``` 56 | sudo nodejs index.js 57 | ``` 58 | or if you require neither LEDs nor buttons 59 | ``` 60 | sudo nodejs index.js --no-gpio 61 | ``` 62 | Or create service using systemctl or init.d. 63 | 64 | Http- and WebSocket- servers will run at port 80 (so you need `sudo`). 65 | Now browse 66 | ``` 67 | http:/// 68 | ``` 69 | or 70 | ``` 71 | http:/// 72 | ``` 73 | and enjoy your favorite stations! 74 | 75 | #### How to deal with files on disk 76 | 77 | First, remember that MPD supposes music files to be located in the special directory. 78 | Check the `music_directory` parameter int the `/etc/mpd.conf` file. 79 | ``` 80 | grep music_directory /etc/mpd.conf 81 | ``` 82 | Locate required music files in that directory and|or it's subdirectories. 83 | 84 | Then, run `make_playlist` script, which will create `playlist.json` file. 85 | ``` 86 | nodejs make_playlist.js 87 | ``` 88 | 89 | Finally, add this playlist to `config.json` 90 | ``` 91 | { 92 | /* .... */ 93 | "include": "./playlist.json", 94 | /* .... */ 95 | } 96 | ``` 97 | And restart `index.js`. -------------------------------------------------------------------------------- /src/client/app/selector/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import css from './index.css'; 3 | 4 | const logoColor = { color: '#808691' }; 5 | 6 | const logoColorsAct = [ 7 | { backgroundColor: '#ffe6e5', color: '#b45a5a'}, 8 | { backgroundColor: '#d2f7d5', color: '#4e976f' }, 9 | { backgroundColor: '#d7eff5', color: '#6288c3' }, 10 | { backgroundColor: '#ffdfc6', color: '#e86d1d' }, 11 | { backgroundColor: '#f2d3f7', color: '#8d53a3' } 12 | ]; 13 | 14 | const genreIcons = { 15 | rock: 'fas fa-fw fa-guitar', 16 | pop: 'fas fa-fw fa-headphones-alt', 17 | lounge: 'fas fa-fw fa-microphone', 18 | none: 'fas fa-fw fa-music', 19 | }; 20 | 21 | const typeIcons = { 22 | radio: 'fas fa-fw fa-podcast', 23 | file: 'far fa-fw fa-folder', 24 | none: 'far fa-fw fa-folder', 25 | }; 26 | 27 | export default class Selector extends React.Component { 28 | constructor(props) { 29 | super(props); 30 | const {playlist, pid} = props; 31 | const canMove = playlist.length > 1; 32 | this.state = { 33 | selectedRow: -1, 34 | selectedPid: canMove ? pid : 0, 35 | canMove 36 | }; 37 | this.selectPlaylist = id => { 38 | if (id < -1 || id >= this.props.playlist.length) return; 39 | this.setState({selectedPid: id}); 40 | }; 41 | this.selectRow = index => this.setState({selectedRow: index}); 42 | } 43 | 44 | renderRoot() { 45 | const {playlist, pid} = this.props; 46 | const items = playlist 47 | .map((list, index) => ( 48 |
  • this.selectPlaylist(index)}> 49 |
    50 |
    52 | 53 |
    54 |
    55 |
    {list.title}
    56 |
  • 57 | )); 58 | return ( 59 |
    60 |
      61 |
    • 62 | 63 |
    • 64 | {items} 65 |
    66 |
    67 | ); 68 | } 69 | 70 | renderPlaylist(selectedPid) { 71 | const {playlist, tid, pid, playerOpen} = this.props; 72 | const {canMove} = this.state; 73 | const tracks = (playlist[selectedPid] || {}).tracks || []; 74 | const items = tracks.map((track, index) => ( 75 |
  • playerOpen(track)}> 76 |
    77 |
    79 | 80 |
    81 |
    82 |
    {track.title}
    83 |
  • 84 | )); 85 | 86 | return ( 87 |
    88 |
      89 |
    • 90 | {'\xa0'} 91 |
    • 92 | {canMove && 93 |
    • this.selectPlaylist(-1)}> 94 |
      95 |
      96 | 97 |
      98 |
      99 |
      ..
      100 |
    • 101 | } 102 | {items} 103 |
    104 |
    105 | ); 106 | } 107 | 108 | render() { 109 | const {selectedPid} = this.state; 110 | return selectedPid === -1 ? this.renderRoot() : this.renderPlaylist(selectedPid); 111 | } 112 | } -------------------------------------------------------------------------------- /src/client/app/index.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Player from './player'; 3 | import Selector from './selector'; 4 | import css from './index.css'; 5 | import WebSocket from 'socket.io-client'; 6 | import albumArt from './album-art'; 7 | import Spotify from './spotify'; 8 | import Intro from './intro'; 9 | 10 | const defaultAlbumArt = (RADIOBOX_DEBUG === '1' ? '/icons' : '') + '/default-album-art.png'; 11 | 12 | const emptyState = { 13 | artist: 'No connection', 14 | track: '', 15 | status: '', 16 | icon: defaultAlbumArt, 17 | pid: -1, 18 | tid: -1, 19 | ready: false 20 | }; 21 | 22 | export default class extends React.PureComponent { 23 | constructor(props) { 24 | super(props); 25 | this.state = { 26 | ...emptyState, 27 | volume: 100, 28 | showVolume: false, 29 | playlist: [] 30 | }; 31 | this.ws = null; 32 | this.connected = false; 33 | 34 | this.poll = (method_, params_) => { 35 | if (!this.connected) return; 36 | this.ws.emit(method_, params_); 37 | }; 38 | 39 | this.doConnect = () => { 40 | if (this.connected || this.ws) return; 41 | this.ws = WebSocket(RADIOBOX_DEBUG === '1' 42 | ? RADIOBOX_HOST 43 | : window.location.href, 44 | {transports: ['websocket']}); 45 | this.ws.on('connect', () => { 46 | this.connected = true; 47 | }); 48 | this.ws.on('state', (state) => { 49 | const {title, name, ...rest} = state; 50 | let artist = null, track = null, icon = null; 51 | if (!title || title === ' ') { 52 | artist = name || 'RadioBox'; 53 | track = ''; 54 | icon = defaultAlbumArt; 55 | } else { 56 | const ar = title.split(' - '); 57 | artist = ar[0] || name || 'RadioBox'; 58 | track = ar[1] || ''; 59 | icon = track 60 | ? this.updateAlbumArt(artist, track) // request album art 61 | : defaultAlbumArt; 62 | } 63 | this.setState({artist, track, icon, ready: true, ...rest}); 64 | }); 65 | this.ws.on('playlist', playlist => { 66 | this.setState({playlist}); 67 | }); 68 | this.ws.on('disconnect', () => { 69 | this.connected = false; 70 | this.setState({...emptyState}); 71 | }); 72 | }; 73 | 74 | this.playerSetVolume = (delta) => { 75 | this.poll('volume', {delta}); 76 | this.setState({showVolume: true}, this.setShowVolume); 77 | }; 78 | 79 | this.playerPlayPause = () => { 80 | this.poll(this.state.status === 'play' ? 'pause' : 'play'); 81 | }; 82 | 83 | this.setShowVolume = () => { 84 | if (this.volumeTimeout) { 85 | clearTimeout(this.volumeTimeout); 86 | this.volumeTimeout = null; 87 | } 88 | this.volumeTimeout = setTimeout(() => this.setState({showVolume: false}), 5000); 89 | }; 90 | } 91 | 92 | componentDidMount() { 93 | this.doConnect(); 94 | } 95 | 96 | selectStation(station) { 97 | const {pid, tid} = station; 98 | this.setState({status: 'waiting'}, () => this.poll('select', {pid, tid})); 99 | } 100 | 101 | changeStation(delta) { 102 | const {pid, tid, playlist} = this.state; 103 | if (pid === -1) return; 104 | const tracks = playlist[pid].tracks; 105 | this.selectStation(tracks[(tid + delta + tracks.length) % tracks.length]); 106 | } 107 | 108 | requestAlbumArt(artist, track) { 109 | return albumArt(artist, track) 110 | .then(icon => Promise.resolve(icon)) 111 | .catch(() => { 112 | if (track) { // if error - there is no image for {artist, track} 113 | return this.requestAlbumArt(artist); // request just artist with empty album 114 | } else { // already requested artist without album 115 | return Promise.resolve(defaultAlbumArt); 116 | } 117 | }); 118 | } 119 | 120 | updateAlbumArt(newArtist, newTrack) { 121 | const {artist, track, icon} = this.state; 122 | // or we have already requested the same icon 123 | if (newArtist !== artist || newTrack !== track) { 124 | this.requestAlbumArt(newArtist, newTrack) 125 | .then(icon => { 126 | // check if it is still the same song 127 | if (this.state.artist === newArtist && this.state.track === newTrack) { 128 | this.setState({icon}) 129 | } 130 | }); 131 | } 132 | return icon; 133 | } 134 | 135 | getContent() { 136 | const {ready, spotifyStatus} = this.state; 137 | if (!ready) { 138 | return ; 139 | } 140 | if (spotifyStatus) { 141 | return this.poll('radio')}/>; 142 | } 143 | const {artist, track, icon, status, playlist, volume, pid, tid, showVolume} = this.state; 144 | return [ 145 | this.changeStation(idx)} 152 | volume={showVolume && volume}/>, 153 | this.selectStation(file)}/> 157 | ]; 158 | } 159 | 160 | render() { 161 | return ( 162 |
    163 | {this.getContent()} 164 |
    165 | ); 166 | } 167 | }; 168 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /src/server/index.js: -------------------------------------------------------------------------------- 1 | const logger = (err) => console.log(err); 2 | const Button = require('./button'); 3 | const Pin = require('./pin').Writer; 4 | const MPC = require('mpc-js').MPC; 5 | const config = loadConfig(); 6 | const app = require('express')(); 7 | const server = require('http').Server(app); 8 | const bp = require("body-parser"); 9 | const io = require('socket.io')(server, { transports: ['websocket'] }); 10 | const { spawn } = require('child_process'); 11 | const mpc = new MPC(); 12 | const btnPlay = new Button(config.pins.buttonPlay, config.timeouts.longPress); 13 | const btnVolD = new Button(config.pins.buttonVolDown, config.timeouts.longPress, config.timeouts.volume); 14 | const btnVolU = new Button(config.pins.buttonVolUp, config.timeouts.longPress, config.timeouts.volume); 15 | const pinMute = new Pin(config.pins.mute); 16 | const pinSmooth = new Pin(config.pins.smooth); 17 | const pinLedWhite = new Pin(config.pins.ledWhite); 18 | const pinLedBlue = new Pin(config.pins.ledBlue); 19 | const state = { }; 20 | const spotify = { 21 | process: null, 22 | delay: null, 23 | exitHandler: null 24 | }; 25 | const buttonHandler = { 26 | handlePause: true, 27 | timeout: 0, 28 | timerBlink: null, 29 | timerVolume: null 30 | }; 31 | 32 | server.listen(process.env.DEBUG === '1' ? 8001 : 80); 33 | app.use(bp.urlencoded({ extended: false })); 34 | 35 | 36 | app.get('/', (req, res) => { 37 | res.sendFile(__dirname + '/client/index.html'); 38 | }); 39 | 40 | app.get('/index.js', (req, res) => { 41 | res.sendFile(__dirname + '/client/index.js'); 42 | }); 43 | 44 | app.get('/manifest.json', (req, res) => { 45 | res.sendFile(__dirname + '/client/manifest.json'); 46 | }); 47 | 48 | app.get('/*.(ico|png)', (req, res) => { 49 | res.sendFile(__dirname + '/client/icons' + req.url); 50 | }); 51 | 52 | app.post('/spotify_event', (req, res) => { 53 | handleSpotifyEvent(req.body.event); 54 | res.send('OK\r\n'); 55 | }); 56 | 57 | function loadPlaylist(file, playlist) { 58 | const rawPlaylist = require(file); 59 | return rawPlaylist 60 | .filter(list => list.tracks && list.tracks.length > 0) 61 | .forEach((list) => { 62 | const pid = playlist.length; 63 | const tracks = list.tracks.map(([title, url, genre], tid) => ({title, url, genre, tid, pid})); 64 | playlist.push({title: list.title, pid, tracks, type: 'file'}); 65 | }); 66 | } 67 | 68 | function loadConfig() { 69 | const {pins, stations, include, timeouts, autoNext, ...rest} = require('./config.json'); 70 | const playlist = []; 71 | if (stations && stations.length) { 72 | const tracks = stations.map(([title, url, genre], tid) => ({title, url, pid: 0, tid, genre})); 73 | playlist.push({title: 'Radio', pid: 0, tracks, type: 'radio'}); 74 | } 75 | if (include) { 76 | loadPlaylist(include, playlist); 77 | } 78 | return { 79 | playlist, 80 | pins: pins || {}, 81 | timeouts: timeouts || {}, 82 | autoNext: !!autoNext, 83 | ...rest 84 | }; 85 | } 86 | 87 | function isAnythingPlaing() { 88 | return (state.status === 'play' || state.spotifyStatus === 'play'); 89 | } 90 | 91 | function updateBlinking() { 92 | if (btnPlay.isPressed() || btnVolD.isPressed() || btnVolU.isPressed()) { 93 | changeBlinking(config.timeouts.fast); 94 | } else { 95 | changeBlinking(isAnythingPlaing() ? 0 : config.timeouts.slow); 96 | } 97 | } 98 | 99 | function updateMute() { 100 | pinMute.set(isAnythingPlaing() ? 1 : 0); 101 | } 102 | 103 | function changeBlinking(timeout) { 104 | if (buttonHandler.timeout === timeout) 105 | return; // do nothing 106 | if (buttonHandler.timerBlink) { 107 | clearInterval(buttonHandler.timerBlink); 108 | } 109 | buttonHandler.timeout = timeout; 110 | if (timeout > 0) { 111 | // set up timer 112 | buttonHandler.timerBlink = setInterval(() => pinLedWhite.toggle().catch(logger), timeout); 113 | if (timeout === config.timeouts.slow) { 114 | pinLedWhite.set(1) 115 | .then(() => pinSmooth.set(1)) 116 | .then(() => pinLedWhite.toggle()) 117 | .catch(logger); 118 | } else { 119 | pinSmooth.set(0) 120 | .then(() => pinLedWhite.toggle()) 121 | .catch(logger); 122 | } 123 | } else { 124 | buttonHandler.timerBlink = null; 125 | pinSmooth.set(0) 126 | .then(() => pinLedWhite.set(1)) 127 | .catch(logger); 128 | } 129 | } 130 | 131 | function updateCurrentSong(song) { 132 | const url = song ? (song.path || '') : ''; 133 | if (!url) { 134 | Object.assign(state, {tid: -1, pid: -1, title: ' ', name: ' ', url: ''}); 135 | // mpc.currentPlaylist.clear(); 136 | return; 137 | } 138 | // try to find song in the currently playing playlist 139 | const prevUrl = state.url; 140 | let pid = state.pid; 141 | let tid = pid > -1 ? config.playlist[pid].tracks.findIndex(track => track.url === url) : -1; 142 | // and if it isn't found 143 | if (tid === -1) { 144 | // try to find playlist to which the song belongs 145 | pid = config.playlist.findIndex(list => { 146 | tid = list.tracks.findIndex(track => track.url === url); 147 | return tid !== -1; 148 | }); 149 | } 150 | const title = song.title || ' '; 151 | const name = song.name || (tid > -1 ? config.playlist[pid].tracks[tid].title : ' '); 152 | // update the state 153 | Object.assign(state, {tid, pid, title, name, url}); 154 | if (prevUrl !== url) { 155 | // if current song has been changed - decide if we should push next track to MPD's queue or not 156 | // The only reason to push next track - is when playing track is file 157 | const autoNext = (tid > -1) && (pid > -1) && (config.playlist[pid].type === 'file'); 158 | // first remove any other tracks from MPD's queue 159 | cropPlaylist(song.id) 160 | .then(() => { 161 | if (autoNext) { 162 | // we have to add the next track to MPD's queue to ensure 163 | const nextTid = (tid + 1) % config.playlist[pid].tracks.length; 164 | return mpc.currentPlaylist.addId(config.playlist[pid].tracks[nextTid].url) 165 | } 166 | return Promise.resolve(); 167 | }) 168 | .then(() => mpc.playbackOptions.setRepeat(!autoNext)) 169 | .catch(logger); 170 | } 171 | } 172 | 173 | function volumeUserToMpd(vol) { 174 | return Math.round(vol * config.volume.max / 100); 175 | } 176 | 177 | function volumeMpdToUser(vol) { 178 | return Math.round(vol * 100 / config.volume.max); 179 | } 180 | 181 | function readState() { 182 | return mpc.status.status() 183 | .then(obj => { 184 | Object.assign(state, { 185 | volume: volumeMpdToUser(obj.volume), 186 | status: (obj.state === 'play' ? 'play' : 'pause') 187 | }); 188 | return mpc.status.currentSong(); 189 | }) 190 | .then(song => { 191 | updateCurrentSong(song); 192 | updateMute(); 193 | updateBlinking(); 194 | return Promise.resolve(state); 195 | }); 196 | } 197 | 198 | function selectSong(pid, tid) { 199 | if (pid < 0 || tid < 0 || pid >= config.playlist.length || tid >= config.playlist[pid].tracks.length) { 200 | return Promise.reject({message: 'wrong playlist id or track id'}); 201 | } 202 | if (state.pid === pid && state.tid === tid) { // check wanted station is the same with currently playing 203 | if (state.status === 'play') { 204 | return onPlayerEvent(); // if so, do nothing, just update state for clients 205 | } else { 206 | return mpc.playback.pause(false); 207 | } 208 | } else { // otherwise, push new item 209 | return mpc.currentPlaylist.addId(config.playlist[pid].tracks[tid].url) 210 | .then(id => mpc.playback.playId(id)) // then play it 211 | .catch(logger); 212 | } 213 | } 214 | 215 | function setVolume(volume) { 216 | let vol_user = +volume; 217 | if (!isNaN(vol_user)) { 218 | vol_user = Math.max(0, Math.min(100, vol_user)); 219 | const vol_mpd = volumeUserToMpd(vol_user); 220 | return mpc.playbackOptions.setVolume(vol_mpd); 221 | } 222 | return Promise.reject('invalid volume'); 223 | } 224 | 225 | function changeVolume(delta) { 226 | if (!state.spotifyStatus) { 227 | // double conversion needed 228 | const vol = volumeMpdToUser(volumeUserToMpd(state.volume) + (delta * config.volume.delta)); 229 | return setVolume(vol).catch(logger); 230 | } else { 231 | return Promise.reject('Cannot \'changeVolume\': abused by Spotify'); 232 | } 233 | } 234 | 235 | function play() { 236 | if (!state.spotifyStatus) { 237 | return mpc.playback.play(); 238 | } else { 239 | return Promise.reject('Cannot \'play\': abused by Spotify'); 240 | } 241 | } 242 | 243 | function pause() { 244 | if (!state.spotifyStatus) { 245 | return mpc.playback.pause(true); 246 | } else { 247 | return Promise.reject('Coonot \'pause\': abused by Spotify'); 248 | } 249 | } 250 | 251 | // Add a connect listener 252 | io.on('connection', client => { 253 | // Success! Now listen to messages to be received 254 | client.on('play', () => play().catch(logger)); 255 | client.on('pause', () => pause().catch(logger)); 256 | client.on('volume', event => changeVolume(event.delta).catch(logger)); 257 | client.on('select', event => selectSong(event.pid, event.tid).catch(logger)); 258 | client.on('radio', () => killSpotifyReceiver(onPlayerEvent)); 259 | client.emit('playlist', config.playlist); 260 | client.emit('state', state); 261 | }); 262 | 263 | const onPlayerEvent = () => readState().then(s => io.emit('state', s)).catch(logger); 264 | 265 | mpc.on('changed-mixer', onPlayerEvent); 266 | mpc.on('changed-player', onPlayerEvent); 267 | 268 | btnPlay.on('down', () => { 269 | buttonHandler.handlePause = !state.spotifyStatus; 270 | }); 271 | 272 | btnPlay.on('long', () => { 273 | buttonHandler.handlePause = false; 274 | if (state.spotifyStatus) { 275 | // if box is abused by Spotify - kill it's process 276 | // and then - try to start playing 277 | killSpotifyReceiver(play); 278 | } else { 279 | const pid = state.pid === -1 ? 0 : state.pid; 280 | const tid = state.tid; 281 | selectSong(pid, (tid + 1) % config.playlist[pid].tracks.length).catch(logger); 282 | } 283 | }); 284 | 285 | btnPlay.on('up', () => { 286 | if (buttonHandler.handlePause) { 287 | state.status === 'play' 288 | ? mpc.playback.pause(true) 289 | : mpc.playback.play(); 290 | } 291 | }); 292 | 293 | btnVolD.on('down', () => changeVolume(-1)); 294 | btnVolD.on('hold', () => changeVolume(-1)); 295 | 296 | btnVolU.on('down', () => changeVolume(+1)); 297 | btnVolU.on('hold', () => changeVolume(+1)); 298 | 299 | /* 300 | This is a sucker punch, that Volumio uses. 301 | MPD cannot read the whole playlist at once (I don't know why), 302 | but it does not properly load a part urls. 303 | So, we need to add new item to the playlist every time we want to switch station 304 | And it finally may cause too big playlist length. 305 | The workaround is to remove from playlist previous items (whose will never be used actually) 306 | if playlist's length is greater than 1 307 | */ 308 | function cropPlaylist(id) { 309 | return mpc.currentPlaylist.playlistInfo().then(items => { 310 | if (items.length > 1) { 311 | items.filter(i => i.id !== id).forEach(i => mpc.currentPlaylist.deleteId(i.id).catch(logger)); 312 | } 313 | return Promise.resolve(); 314 | }); 315 | } 316 | 317 | /* 318 | Forces MPD to start playing if it does not yet 319 | */ 320 | function handleBoot(state) { 321 | if (state.volume > 100) { 322 | // in case of over-big volume value 323 | setVolume(volumeMpdToUser(config.volume.def)); 324 | } 325 | if (config.autoPlay) { 326 | if (state.status === 'play') { 327 | return Promise.resolve(); 328 | } 329 | return (!state.url || state.url === ' ') 330 | ? selectSong(0, 0) 331 | : mpc.playback.play(); 332 | } else { 333 | return (state.status === 'play') ? mpc.playback.pause() : Promise.resolve(); 334 | } 335 | 336 | } 337 | 338 | function connectMpc() { 339 | return (process.env.DEBUG === '1') 340 | ? mpc.connectTCP(process.env.HOST, 6600) 341 | : mpc.connectUnixSocket('/run/mpd/socket'); 342 | } 343 | 344 | function checkConfig() { 345 | if (!config.volume) { 346 | config.volume = { 347 | max: 100, 348 | def: 10, 349 | delta: 1 350 | } 351 | } else { 352 | const vol = config.volume; 353 | vol.max = (typeof vol.max === 'number') ? Math.max(10, Math.min(100, vol.max)) : 100; 354 | vol.def = (typeof vol.def === 'number') ? Math.max(1, Math.min(vol.max, vol.def)) : 10; 355 | vol.delta = (typeof vol.delta === 'number') ? Math.max(1, Math.min(5, vol.delta)) : 1; 356 | } 357 | return config.playlist.length > 0 358 | ? Promise.resolve() 359 | : Promise.reject(new Error('Playlist should not be empty!')); 360 | } 361 | 362 | function runSpotifyReceiver() { 363 | if (!config.spotify || !config.spotify.enabled) { 364 | return Promise.resolve(); 365 | } else { 366 | const {bitrate, backend, device, volume, restartTimeout} = config.spotify; 367 | const evt_script = __dirname + '/onevent.sh'; 368 | const options = [ 369 | '--name', 'RadioBox', 370 | '--autoplay', 371 | '--bitrate', bitrate || 320, 372 | '--enable-volume-normalisation', 373 | '--initial-volume', volume || 25, 374 | '--disable-audio-cache', 375 | '--onevent', evt_script 376 | ]; 377 | if (backend) { 378 | options.push('--backend', backend); 379 | } 380 | if (device) { 381 | options.push('--device', device); 382 | } 383 | // launch Spotify Connect receiver process 384 | console.log('trying to start librespot process'); 385 | ps = spawn('/usr/bin/librespot', options); 386 | ps.stdout.pipe(process.stdout); 387 | ps.stderr.pipe(process.stderr); 388 | console.log(`librespot process started with pid ${ps.pid}`); 389 | // update state with new information 390 | state.spotifyStatus = null; 391 | spotify.process = ps; 392 | spotify.delay = null; 393 | spotify.exitHandler = null; 394 | // handle exit event 395 | ps.on('close', (code) => { 396 | console.log(`librespot process exited with code ${code}`); 397 | // if there is 398 | const handler = spotify.exitHandler; 399 | state.spotifyStatus = null; 400 | spotify.process = null; 401 | spotify.exitHandler = null; 402 | if (!spotify.delay) { 403 | spotify.delay = setTimeout(runSpotifyReceiver, restartTimeout || 3000); 404 | } 405 | handler && handler(); 406 | }); 407 | return Promise.resolve(); 408 | } 409 | } 410 | 411 | function handleSpotifyEvent(event) { 412 | switch (event) { 413 | case 'volume_set': 414 | case 'start': 415 | if (!state.spotifyStatus) { 416 | state.spotifyStatus = 'ready'; 417 | if (state.status === 'play') 418 | mpc.playback.pause(true); 419 | } 420 | break; 421 | case 'stop': 422 | state.spotifyStatus = null; 423 | onPlayerEvent(); 424 | break; 425 | case 'playing': 426 | state.spotifyStatus = 'play'; 427 | onPlayerEvent(); 428 | break; 429 | case 'paused': 430 | state.spotifyStatus = 'pause'; 431 | onPlayerEvent(); 432 | break; 433 | } 434 | } 435 | 436 | function killSpotifyReceiver(handler) { 437 | if (spotify.process) { 438 | console.log('trying to kill librespot process'); 439 | spotify.exitHandler = handler; 440 | spotify.process.kill() 441 | } 442 | } 443 | 444 | checkConfig() 445 | .then(() => connectMpc()) 446 | .then(() => mpc.playbackOptions.setConsume(false)) 447 | .then(() => mpc.playbackOptions.setRandom(false)) 448 | .then(() => mpc.playbackOptions.setSingle(false)) 449 | .then(() => btnPlay.setup(updateBlinking)) 450 | .then(() => btnVolD.setup(updateBlinking)) 451 | .then(() => btnVolU.setup(updateBlinking)) 452 | .then(() => pinMute.setup()) 453 | .then(() => pinSmooth.setup()) 454 | .then(() => pinLedWhite.setup()) 455 | .then(() => pinLedBlue.setup()) 456 | .then(() => pinSmooth.set(0)) // turns of smooth blinking 457 | .then(() => pinLedWhite.set(1)) // turns on white LED 458 | .then(() => pinLedBlue.set(1)) // turns off blue LED 459 | .then(() => readState()) // read state 460 | .then(s => handleBoot(s)) // and call boot state handler 461 | .then(() => runSpotifyReceiver()) // run Spotify Connect receiver if configured 462 | .catch(err => { 463 | logger(err); 464 | // exit if there is no connection to mpd 465 | process.exit(1); 466 | }); 467 | --------------------------------------------------------------------------------