├── .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 |
18 |
19 |

20 |
21 |
22 |
23 | {artist}
24 |
25 |
26 | {track}
27 |
28 |
29 |
46 |
);
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 | 
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 |
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 |
82 | {track.title}
83 |
84 | ));
85 |
86 | return (
87 |
88 |
89 | -
90 | {'\xa0'}
91 |
92 | {canMove &&
93 | - this.selectPlaylist(-1)}>
94 |
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 |
--------------------------------------------------------------------------------