├── screenshot.png
├── screenshot2.png
├── public
├── nodejs-logo.png
├── index.css
└── index.html
├── engine
├── now-playing.js
├── view.js
├── controls.js
├── playlist.js
├── shared
│ ├── configs.js
│ └── abstract-classes.js
├── index.js
└── queue.js
├── config
└── index.js
├── utils
└── index.js
├── .eslintrc.json
├── app.js
├── package.json
├── LICENSE
├── routes
└── index.js
├── .gitignore
└── README.md
/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DarkMannn/node-radio-mini/HEAD/screenshot.png
--------------------------------------------------------------------------------
/screenshot2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DarkMannn/node-radio-mini/HEAD/screenshot2.png
--------------------------------------------------------------------------------
/public/nodejs-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/DarkMannn/node-radio-mini/HEAD/public/nodejs-logo.png
--------------------------------------------------------------------------------
/public/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | background-color: rgb(73, 153, 100)
3 | }
4 |
5 | #main {
6 | display: flex;
7 | flex-direction: column;
8 | justify-content: center;
9 | align-items: center;
10 | margin: 10% 30% 0% 30%;
11 | border: 2px solid black;
12 | border-radius: 25px;
13 | background-color: rgb(156, 156, 156);
14 | }
15 |
16 | #title {
17 | font-family: "Comic Sans MS", "Comic Sans", cursive;
18 | font-weight: bold;
19 | }
20 |
21 | #logo {
22 | margin-bottom: 20px
23 | }
24 |
--------------------------------------------------------------------------------
/engine/now-playing.js:
--------------------------------------------------------------------------------
1 | const NeoBlessed = require('neo-blessed');
2 | const AbstractClasses = require('./shared/abstract-classes');
3 |
4 | /**
5 | * Class in charge of:
6 | * - a view layer for the currently playing song
7 | */
8 | class NowPlaying extends AbstractClasses.TerminalItemBox {
9 |
10 | _createBoxChild(content) {
11 |
12 | return NeoBlessed.box({
13 | ...this._childConfig,
14 | top: 0,
15 | content: `>>> ${content}`
16 | });
17 | }
18 | }
19 |
20 | module.exports = NowPlaying;
21 |
--------------------------------------------------------------------------------
/config/index.js:
--------------------------------------------------------------------------------
1 | const Path = require('path');
2 | require('dotenv').config({ path: Path.join(__dirname, '../.env') });
3 |
4 | exports.keys = {
5 | SCROLL_UP: process.env.SCROLL_UP || 'i',
6 | SCROLL_DOWN: process.env.SCROLL_DOWN || 'k',
7 | MOVE_UP: process.env.MOVE_UP || 'w',
8 | MOVE_DOWN: process.env.MOVE_DOWN || 's',
9 | QUEUE_REMOVE: process.env.QUEUE_REMOVE || 'z',
10 | QUEUE_ADD: process.env.QUEUE_ADD || 'enter',
11 | FOCUS_QUEUE: process.env.FOCUS_QUEUE || 'q',
12 | FOCUS_PLAYLIST: process.env.FOCUS_PLAYLIST || 'p'
13 | };
14 |
--------------------------------------------------------------------------------
/utils/index.js:
--------------------------------------------------------------------------------
1 | const Fs = require('fs');
2 | const { extname } = require('path');
3 |
4 | const _readDir = () => Fs.readdirSync(process.cwd(), { withFileTypes: true });
5 | const _isMp3 = item => item.isFile && extname(item.name) === '.mp3';
6 |
7 | exports.readSong = () => _readDir().filter(_isMp3)[0].name;
8 | exports.readSongs = () => _readDir().filter(_isMp3).map((songItem) => songItem.name);
9 |
10 | exports.discardFirstWord = str => str.substring(str.indexOf(' ') + 1);
11 | exports.getFirstWord = str => str.split(' ')[0];
12 |
13 | exports.generateRandomId = () => Math.random().toString(36).slice(2);
14 |
--------------------------------------------------------------------------------
/engine/view.js:
--------------------------------------------------------------------------------
1 | const NeoBlessed = require('neo-blessed');
2 |
3 | /**
4 | * Class that wraps the neo-blessed screen i.e. the entire view layer
5 | */
6 | class View {
7 |
8 | constructor() {
9 |
10 | const screen = NeoBlessed.screen({ smartSCR: true });
11 | screen.title = 'Node Radio Mini';
12 | screen.key(['escape', 'C-c'], () => process.exit(0));
13 | this._screen = screen;
14 | }
15 |
16 | appendBoxes(boxes) {
17 | for (const box of boxes) {
18 | this._screen.append(box);
19 | }
20 | }
21 |
22 | render() {
23 | this._screen.render();
24 | }
25 | }
26 |
27 | module.exports = View;
28 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Node.js Radio
7 |
8 |
9 |
10 |
11 |
12 |
13 |
Very Cool Radio
14 |

15 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "commonjs": true,
5 | "es6": true,
6 | "node": true
7 | },
8 | "extends": "eslint:recommended",
9 | "parserOptions": {
10 | "ecmaVersion": 2018
11 | },
12 | "rules": {
13 | "indent": [
14 | "error",
15 | 4
16 | ],
17 | "linebreak-style": [
18 | "error",
19 | "unix"
20 | ],
21 | "quotes": [
22 | "error",
23 | "single"
24 | ],
25 | "semi": [
26 | "error",
27 | "always"
28 | ],
29 | "no-console": "off",
30 | "no-unused-vars": "off",
31 | "no-irregular-whitespace": "error"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | require('./config');
4 | const Hapi = require('@hapi/hapi');
5 | const StaticFilePlugin = require('@hapi/inert');
6 | const Path = require('path');
7 | const Routes = require('./routes');
8 | const Engine = require('./engine');
9 |
10 | void async function startApp() {
11 |
12 | try {
13 | const server = Hapi.server({
14 | port: process.env.PORT || 8080,
15 | host: process.env.HOST || 'localhost',
16 | compression: false,
17 | routes: { files: { relativeTo: Path.join(__dirname, 'public') } }
18 | });
19 | await server.register(StaticFilePlugin);
20 | await server.register(Routes);
21 |
22 | Engine.start();
23 | await server.start();
24 | console.log(`Server running at: ${server.info.uri}`);
25 | }
26 | catch (err) {
27 | console.log(`Server errored with: ${err}`);
28 | console.error(err.stack);
29 | process.exit(1);
30 | }
31 | }();
32 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "node-radio-mini",
3 | "version": "0.0.4",
4 | "description": "A terminal based radio streaming solution made entirely in Node.js",
5 | "homepage": "https://github.com/DarkMannn/node-radio-mini",
6 | "repository": "https://github.com/DarkMannn/node-radio-mini",
7 | "main": "app.js",
8 | "scripts": {
9 | "test": "echo \"Error: no test specified\" && exit 1"
10 | },
11 | "bin": {
12 | "node-radio-mini": "./app.js"
13 | },
14 | "dependencies": {
15 | "@dropb/ffprobe": "^1.4.2",
16 | "@hapi/hapi": "^19.x.x",
17 | "@hapi/inert": "^5.2.2",
18 | "dotenv": "^8.2.0",
19 | "neo-blessed": "^0.2.0",
20 | "throttle": "^1.0.3"
21 | },
22 | "devDependencies": {
23 | "eslint": "^5.10.0"
24 | },
25 | "keywords": [
26 | "node",
27 | "nodejs",
28 | "radio",
29 | "song",
30 | "live",
31 | "streaming",
32 | "internet",
33 | "cli",
34 | "terminal"
35 | ],
36 | "author": "DarkMannn",
37 | "license": "MIT"
38 | }
39 |
--------------------------------------------------------------------------------
/engine/controls.js:
--------------------------------------------------------------------------------
1 | const AbstractClasses = require('./shared/abstract-classes');
2 | const { keys } = require('../config');
3 |
4 | /**
5 | * Class in charge of:
6 | * - a view layer for the available controls/keys
7 | */
8 | class Controls extends AbstractClasses.TerminalBox {
9 |
10 | constructor(config) {
11 | super(config);
12 | this.setPlaylistTips();
13 | }
14 |
15 | setPlaylistTips() {
16 | this.box.content =
17 | ` ${keys.FOCUS_QUEUE} - focus queue | ${keys.SCROLL_UP} - go up\n` +
18 | ` ${keys.QUEUE_ADD} - enqueue song | ${keys.SCROLL_DOWN} - go down\n`;
19 | }
20 |
21 | setQueueTips() {
22 | this.box.content =
23 | ` ${keys.MOVE_UP} - move song up | ${keys.SCROLL_UP} - go up\n` +
24 | ` ${keys.MOVE_DOWN} - move zong down | ${keys.SCROLL_DOWN} - go down\n` +
25 | ` ${keys.FOCUS_PLAYLIST} - focus playlist | ${keys.QUEUE_REMOVE} - dequeue son`;
26 | }
27 | }
28 |
29 | module.exports = Controls;
30 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Darko
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/routes/index.js:
--------------------------------------------------------------------------------
1 | const { queue } = require('../engine');
2 |
3 | const plugin = {
4 | name: 'streamServer',
5 | register: async (server) => {
6 |
7 | server.route({
8 | method: 'GET',
9 | path: '/',
10 | handler: (_, h) => h.file('index.html')
11 | });
12 |
13 | server.route({
14 | method: 'GET',
15 | path: '/{filename}',
16 | handler: {
17 | file: (req) => req.params.filename
18 | }
19 | });
20 |
21 | server.route({
22 | method: 'GET',
23 | path: '/stream',
24 | handler: (request, h) => {
25 |
26 | const { id, responseSink } = queue.makeResponseSink();
27 | request.app.sinkId = id;
28 | return h.response(responseSink).type('audio/mpeg');
29 | },
30 | options: {
31 | ext: {
32 | onPreResponse: {
33 | method: (request, h) => {
34 |
35 | request.events.once('disconnect', () => {
36 | queue.removeResponseSink(request.app.sinkId);
37 | });
38 | return h.continue;
39 | }
40 | }
41 | }
42 | }
43 | });
44 | }
45 | };
46 |
47 | module.exports = plugin;
48 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (https://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | jspm_packages/
38 |
39 | # TypeScript v1 declaration files
40 | typings/
41 |
42 | # Optional npm cache directory
43 | .npm
44 |
45 | # Optional eslint cache
46 | .eslintcache
47 |
48 | # Optional REPL history
49 | .node_repl_history
50 |
51 | # Output of 'npm pack'
52 | *.tgz
53 |
54 | # Yarn Integrity file
55 | .yarn-integrity
56 |
57 | # dotenv environment variables file
58 | .env
59 |
60 | # parcel-bundler cache (https://parceljs.org/)
61 | .cache
62 |
63 | # next.js build output
64 | .next
65 |
66 | # nuxt.js build output
67 | .nuxt
68 |
69 | # vuepress build output
70 | .vuepress/dist
71 |
72 | # Serverless directories
73 | .serverless/
74 |
75 | # FuseBox cache
76 | .fusebox/
77 |
78 | # DynamoDB Local files
79 | .dynamodb/
80 |
81 | # test music
82 | music/**
83 |
84 | # notes
85 | todo
86 |
--------------------------------------------------------------------------------
/engine/playlist.js:
--------------------------------------------------------------------------------
1 | const NeoBlessed = require('neo-blessed');
2 | const AbstractClasses = require('./shared/abstract-classes');
3 | const { keys } = require('../config');
4 |
5 | /**
6 | * Class in charge of:
7 | * - view layer for the list of available songs
8 | */
9 | class Playlist extends AbstractClasses.TerminalItemBox {
10 |
11 | _createBoxChild(content) {
12 |
13 | return NeoBlessed.box({
14 | ...this._childConfig,
15 | top: this.box.children.length - 1,
16 | content: `- ${content}`
17 | });
18 | }
19 |
20 | fillWithItems(items) {
21 | for (const item of items) {
22 | this.createBoxChildAndAppend(item);
23 | }
24 | this.focus();
25 | }
26 |
27 | getFocusedSong() {
28 | const child = this.box.children[this._focusIndexer.get()];
29 | return child && child.content;
30 | }
31 |
32 | _doChildrenOverflow() {
33 | return this._getHeight() < this.box.children.length;
34 | }
35 |
36 | _circleChildrenUp() {
37 | const temp = this.box.children[this.box.children.length - 1].content;
38 | this.box.children.reduceRight((lowerChild, upperChild) => {
39 |
40 | lowerChild.content = upperChild.content;
41 | return upperChild;
42 | });
43 | this.box.children[1].content = temp;
44 | }
45 |
46 | _circleChildrenDown() {
47 | const temp = this.box.children[1].content;
48 | this.box.children.reduce((upperChild, lowerChild, index) => {
49 |
50 | if (index > 1) {
51 | upperChild.content = lowerChild.content;
52 | }
53 | return lowerChild;
54 | });
55 | this.box.children[this.box.children.length - 1].content = temp;
56 | }
57 |
58 | _circleList(key) {
59 | if (this._focusIndexer.get() === 1 && key === keys.SCROLL_UP) {
60 | this._circleChildrenUp();
61 | }
62 | else if (this._focusIndexer.get() === this._getHeight() && key === keys.SCROLL_DOWN) {
63 | this._circleChildrenDown();
64 | }
65 | }
66 |
67 | scroll(scrollKey) {
68 | if (this.box.children.length > 2 && this._doChildrenOverflow()) {
69 | this._circleList(scrollKey);
70 | }
71 | super.scroll(scrollKey);
72 | }
73 | }
74 |
75 | module.exports = Playlist;
76 |
--------------------------------------------------------------------------------
/engine/shared/configs.js:
--------------------------------------------------------------------------------
1 | const commonConfig = {
2 | border: { type: 'line' }
3 | };
4 |
5 | const childCommonConfig = {
6 | width: '100%',
7 | height: 1,
8 | left: 0
9 | };
10 |
11 | const Configs = {
12 | playlist: {
13 | bgFocus: 'black',
14 | bgBlur: 'green',
15 | config: {
16 | ...commonConfig,
17 | top: 0,
18 | left: 0,
19 | width: '50%',
20 | height: '100%',
21 | scrollable: true,
22 | label: 'Playlist',
23 | style: {
24 | fg: 'white',
25 | bg: 'green',
26 | border: {
27 | fg: '#f0f0f0'
28 | }
29 | }
30 | },
31 | childConfig: {
32 | ...childCommonConfig,
33 | fg: 'white',
34 | bg: 'green'
35 | }
36 | },
37 | queue: {
38 | bgFocus: 'black',
39 | bgBlur: 'blue',
40 | config: {
41 | ...commonConfig,
42 | top: 0,
43 | left: '50%',
44 | width: '50%',
45 | height: '70%',
46 | scrollable: true,
47 | label: 'Queue',
48 | style: {
49 | fg: 'white',
50 | bg: 'blue',
51 | border: {
52 | fg: '#f0f0f0'
53 | }
54 | }
55 | },
56 | childConfig: {
57 | ...childCommonConfig,
58 | fg: 'white',
59 | bg: 'blue'
60 | }
61 | },
62 | nowPlaying: {
63 | config: {
64 | ...commonConfig,
65 | top: '70%',
66 | left: '50%',
67 | width: '50%',
68 | height: 3,
69 | label: 'Now Playing',
70 | style: {
71 | fg: 'white',
72 | bg: 'black',
73 | border: {
74 | fg: '#f0f0f0'
75 | }
76 | }
77 | },
78 | childConfig: {
79 | ...childCommonConfig,
80 | fg: 'green',
81 | bg: 'black'
82 | }
83 | },
84 | controls: {
85 | config: {
86 | ...commonConfig,
87 | top: '85%',
88 | left: '50%',
89 | width: '50%',
90 | height: 5,
91 | scrollable: true,
92 | label: 'Controls',
93 | style: {
94 | fg: 'grey',
95 | bg: 'black',
96 | border: {
97 | fg: '#000000'
98 | }
99 | }
100 | }
101 | }
102 | };
103 |
104 | module.exports = Configs;
105 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Node.js radio mini
2 |
3 | ## Description
4 |
5 | This app is a radio streaming solution made entirely in Node.js. It features a terminal GUI (there are sections for the playlist, the song queue, the currently playing song and for the keyboard controls) and a http endpoint at which the songs are going to get streamed.
6 |
7 | Purpose of the whole project was to have fun and experiment. Production ready radio server should use Shoutcast / Icecast (or similar) for a robust streaming server.
8 |
9 | ## Requirements
10 |
11 | You must have `ffprobe` installed, which is part of `ffmpeg`, on your operating system in order for this app to work, since the javascript code relies on that binary to exist.
12 |
13 | ## Installation
14 |
15 | Clone this repository. Go into the root and run:
16 |
17 | ```
18 | > npm install
19 | > npm link
20 | ```
21 |
22 | These commands will make a `node-radio-mini` command available to be run from anywhere in your terminal.
23 |
24 | ## Usage
25 |
26 | Go into a directory that contains music files (for now only mp3 format is supported), and run the command `node-radio-mini`.
27 |
28 | ```
29 | > node-radio-mini
30 | ```
31 |
32 | That command is going to read all mp3 files from the current directory and display them on you favorite terminal, similarly like on the image below.
33 |
34 | 
35 |
36 | There are four windows. 'Playlist' windows contains all the songs from you current directory. 'Queue' windows contains all queued up and ready to play songs. 'Now playing' windows is showing currently streamed song. 'Controls' window is just a helper for seeing available controls at that point of time.
37 |
38 | First song is going to get automatically queued up and played. Songs are streamed to the endpoint `http://process.env.HOST:process.env.PORT/stream`, or if you didn't set any env variables the default would be `http://localhost:8080/stream`.
39 |
40 | If you don't have any songs queued up, the last song will be played again.
41 |
42 | This app is also serving a single html page that will automatically connect to the streaming endpoint shown above. The page is served at `http://localhost:8080`. You can see how the page looks in the browser in the next screenshot:
43 |
44 | 
45 |
46 | ### Commands
47 |
48 | When the 'playlist' window is focused available commands are:
49 |
50 | - q - switch focus to 'queue' window (or process.env.FOCUS_QUEUE)
51 | - i - scroll up in the playlist (or process.env.UP)
52 | - k - scroll down in the playlist (or process.env.SCROLL_DOWN)
53 | - enter - enqueue selected song (or process.env.QUEUE_ADD)
54 |
55 | When the 'queue' window is focused available commands are:
56 |
57 | - p - switch focus to 'playlist' window (or process.env.PLAYLIST)
58 | - i - scroll up in the queue (or process.env.SCROLL_UP)
59 | - k - scroll down in the queue (or process.env.SCROLL_DOWN)
60 | - z - dequeue selected song (or process.env.QUEUE_REMOVE)
61 | - w - move selected song up the queue (or process.env.MOVE_UP)
62 | - s - move selected song down the queue (or process.env.MOVE_DOWN)
63 |
--------------------------------------------------------------------------------
/engine/shared/abstract-classes.js:
--------------------------------------------------------------------------------
1 | const NeoBlessed = require('neo-blessed');
2 | const { keys } = require('../../config');
3 |
4 | class _FocusIndexer {
5 |
6 | constructor({ getIndexLimit }) {
7 | this._index = 1;
8 | this._getIndexLimit = getIndexLimit;
9 | }
10 |
11 | get() {
12 | return this._index;
13 | }
14 |
15 | incr() {
16 | if (this._index < this._getIndexLimit()) {
17 | this._index++;
18 | }
19 | }
20 |
21 | decr() {
22 | if (this._index > 1) {
23 | this._index--;
24 | }
25 | }
26 | }
27 |
28 | /**
29 | * Base Class that wraps neo-blessed library and creates a view box i.e. window
30 | */
31 | class TerminalBox {
32 |
33 | constructor(config) {
34 | this.box = NeoBlessed.box(config);
35 | }
36 | }
37 |
38 | /**
39 | * Class extended over TerminalBox class,
40 | * wraps neo-blessed library and creates a view box i.e. window with scrollable children
41 | */
42 | class TerminalItemBox extends TerminalBox {
43 |
44 | constructor({ config, childConfig, bgBlur, bgFocus }) {
45 |
46 | super(config);
47 | this._childConfig = childConfig;
48 | this._bgBlur = bgBlur;
49 | this._bgFocus = bgFocus;
50 | this._focusIndexer = new _FocusIndexer({
51 | getIndexLimit: this._getNavigationLimit.bind(this)
52 | });
53 | }
54 |
55 | _getHeight() {
56 | // neo-blessed box has two invisible items prepended, so we need '-2'
57 | return this.box.height - 2;
58 | }
59 |
60 | _getNavigationLimit() {
61 | return Math.min(this.box.children.length - 1, this._getHeight());
62 | }
63 |
64 | _setActiveChildColor(color) {
65 | const activeChild = this.box.children[this._focusIndexer.get()];
66 | if (activeChild) {
67 | activeChild.style.bg = color;
68 | }
69 | }
70 |
71 | focus() {
72 | this._setActiveChildColor(this._bgFocus);
73 | this.box.focus();
74 | }
75 |
76 | blur() {
77 | this._setActiveChildColor(this._bgBlur);
78 | }
79 |
80 | scroll(scrollKey) {
81 |
82 | if (this.box.children.length === 1) {
83 | return;
84 | }
85 |
86 | const unfocusedIndex = this._focusIndexer.get();
87 | const unfocusedChild = this.box.children[unfocusedIndex];
88 | unfocusedChild.style.bg = this._bgBlur;
89 |
90 | if (scrollKey === keys.SCROLL_UP) {
91 | this._focusIndexer.decr();
92 | }
93 | else if (scrollKey === keys.SCROLL_DOWN) {
94 | this._focusIndexer.incr();
95 | }
96 |
97 | const focusedIndex = this._focusIndexer.get();
98 | const focusedChild = this.box.children[focusedIndex];
99 | focusedChild.style.bg = this._bgFocus;
100 | }
101 |
102 | _createBoxChild() {
103 | throw new Error('_createBoxChild() method not implemented');
104 | }
105 |
106 | createBoxChildAndAppend(content) {
107 | const boxChild = this._createBoxChild(content);
108 | this.box.append(boxChild);
109 | }
110 | }
111 |
112 | module.exports = {
113 | TerminalBox,
114 | TerminalItemBox
115 | };
116 |
--------------------------------------------------------------------------------
/engine/index.js:
--------------------------------------------------------------------------------
1 | const { keys } = require('../config');
2 | const Utils = require('../utils');
3 | const Configs = require('./shared/configs');
4 |
5 | const View = require('./view');
6 | const Playlist = require('./playlist');
7 | const Queue = require('./queue');
8 | const NowPlaying = require('./now-playing');
9 | const Controls = require('./controls');
10 |
11 | const view = new View();
12 | const playlist = new Playlist({
13 | config: Configs.playlist.config,
14 | childConfig: Configs.playlist.childConfig,
15 | bgBlur: Configs.playlist.bgBlur,
16 | bgFocus: Configs.playlist.bgFocus
17 | });
18 | const queue = new Queue({
19 | config: Configs.queue.config,
20 | childConfig: Configs.queue.childConfig,
21 | bgBlur: Configs.queue.bgBlur,
22 | bgFocus: Configs.queue.bgFocus
23 | });
24 | const nowPlaying = new NowPlaying({
25 | config: Configs.nowPlaying.config,
26 | childConfig: Configs.nowPlaying.childConfig
27 | });
28 | const controls = new Controls(Configs.controls.config);
29 |
30 | const _addPlaylistAndQueueListeners = () => {
31 |
32 | /**
33 | * listeners for the playlist box (playlist's view layer events)
34 | */
35 | const playlistOnScroll = (scrollKey) => {
36 |
37 | playlist.scroll(scrollKey);
38 | view.render();
39 | };
40 | playlist.box.key(keys.SCROLL_UP, playlistOnScroll);
41 | playlist.box.key(keys.SCROLL_DOWN, playlistOnScroll);
42 |
43 | playlist.box.key(keys.QUEUE_ADD, () => {
44 |
45 | const focusedSong = playlist.getFocusedSong();
46 | const formattedSong = Utils.discardFirstWord(focusedSong);
47 | queue.createAndAppendToQueue(formattedSong);
48 | view.render();
49 | });
50 |
51 | playlist.box.key(keys.FOCUS_QUEUE, () => {
52 |
53 | playlist.blur();
54 | queue.focus();
55 | controls.setQueueTips();
56 | view.render();
57 | });
58 |
59 | /**
60 | * listeners for the queue box (queue's view layer events)
61 | */
62 | const queueOnScroll = (scrollKey) => {
63 |
64 | queue.scroll(scrollKey);
65 | view.render();
66 | };
67 | queue.box.key(keys.SCROLL_UP, queueOnScroll);
68 | queue.box.key(keys.SCROLL_DOWN, queueOnScroll);
69 |
70 | const queueOnMove = (key) => {
71 |
72 | queue.changeOrderQueue(key);
73 | view.render();
74 | };
75 | queue.box.key(keys.MOVE_UP, queueOnMove);
76 | queue.box.key(keys.MOVE_DOWN, queueOnMove);
77 |
78 | queue.box.key(keys.QUEUE_REMOVE, () => {
79 |
80 | queue.removeFromQueue();
81 | queue.focus();
82 | view.render();
83 | });
84 |
85 | queue.box.key(keys.FOCUS_PLAYLIST, () => {
86 |
87 | queue.blur();
88 | playlist.focus();
89 | controls.setPlaylistTips();
90 | view.render();
91 | });
92 |
93 | /**
94 | * listeners for the queue streams (queue's stream events)
95 | */
96 | queue.stream.on('play', (song) => {
97 |
98 | playlist.focus();
99 | nowPlaying.createBoxChildAndAppend(song);
100 | view.render();
101 | });
102 | };
103 |
104 | exports.start = () => {
105 |
106 | _addPlaylistAndQueueListeners();
107 | playlist.fillWithItems(Utils.readSongs());
108 | view.appendBoxes([playlist.box, queue.box, nowPlaying.box, controls.box]);
109 | view.render();
110 | queue.init();
111 | queue.startStreaming();
112 | };
113 |
114 | exports.queue = queue;
115 | exports.playlist = playlist;
116 |
--------------------------------------------------------------------------------
/engine/queue.js:
--------------------------------------------------------------------------------
1 | const Fs = require('fs');
2 | const Path = require('path');
3 | const EventEmitter = require('events');
4 | const { PassThrough } = require('stream');
5 |
6 | const Throttle = require('throttle');
7 | const NeoBlessed = require('neo-blessed');
8 | const { ffprobeSync } = require('@dropb/ffprobe');
9 |
10 | const AbstractClasses = require('./shared/abstract-classes');
11 | const Utils = require('../utils');
12 | const { keys } = require('../config');
13 |
14 | /**
15 | * Class in charge of:
16 | * 1. A view layer for the queued up songs
17 | * - 'this.box.children' contains view layer for the queued up songs
18 | * 2. A stream layer for the streaming of the queued up songs
19 | * - 'this_songs' contains songs for the streaming
20 | */
21 | class Queue extends AbstractClasses.TerminalItemBox {
22 |
23 | constructor(params) {
24 | super(params);
25 | this._sinks = new Map(); // map of active sinks/writables
26 | this._songs = []; // list of queued up songs
27 | this._currentSong = null;
28 | this.stream = new EventEmitter();
29 | }
30 |
31 | init() {
32 | this._currentSong = Utils.readSong();
33 | }
34 |
35 | makeResponseSink() {
36 | const id = Utils.generateRandomId();
37 | const responseSink = PassThrough();
38 | this._sinks.set(id, responseSink);
39 | return { id, responseSink };
40 | }
41 |
42 | removeResponseSink(id) {
43 | this._sinks.delete(id);
44 | }
45 |
46 | _broadcastToEverySink(chunk) {
47 | for (const [, sink] of this._sinks) {
48 | sink.write(chunk);
49 | }
50 | }
51 |
52 | _getBitRate(song) {
53 | try {
54 | const bitRate = ffprobeSync(Path.join(process.cwd(), song)).format.bit_rate;
55 | return parseInt(bitRate);
56 | }
57 | catch (err) {
58 | return 128000; // reasonable default
59 | }
60 | }
61 |
62 | _playLoop() {
63 |
64 | this._currentSong = this._songs.length
65 | ? this.removeFromQueue({ fromTop: true })
66 | : this._currentSong;
67 | const bitRate = this._getBitRate(this._currentSong);
68 |
69 | const songReadable = Fs.createReadStream(this._currentSong);
70 |
71 | const throttleTransformable = new Throttle(bitRate / 8);
72 | throttleTransformable.on('data', (chunk) => this._broadcastToEverySink(chunk));
73 | throttleTransformable.on('end', () => this._playLoop());
74 |
75 | this.stream.emit('play', this._currentSong);
76 | songReadable.pipe(throttleTransformable);
77 | }
78 |
79 | startStreaming() {
80 | this._playLoop();
81 | }
82 |
83 | _createBoxChild(content) {
84 |
85 | return NeoBlessed.box({
86 | ...this._childConfig,
87 | top: this.box.children.length - 1,
88 | content: `${this.box.children.length}. ${content}`
89 | });
90 | }
91 |
92 | _boxChildrenIndexToSongsIndex(index) {
93 | // converts index of this.box.children array (view layer)
94 | // to the index of this._songs array (stream layer)
95 | return index - 1;
96 | }
97 |
98 | _createAndAppendToSongs(song) {
99 | this._songs.push(song);
100 | }
101 |
102 | _createAndAppendToBoxChildren(song) {
103 | this.createBoxChildAndAppend(song);
104 | }
105 |
106 | createAndAppendToQueue(song) {
107 | this._createAndAppendToBoxChildren(song);
108 | this._createAndAppendToSongs(song);
109 | }
110 |
111 | _removeFromSongs(index) {
112 | const adjustedIndex = this._boxChildrenIndexToSongsIndex(index);
113 | return this._songs.splice(adjustedIndex, 1);
114 | }
115 |
116 | _discardFromBox(index) {
117 | this.box.remove(this.box.children[index]);
118 | }
119 |
120 | _orderBoxChildren() {
121 | this.box.children.forEach((child, index) => {
122 |
123 | if (index !== 0) {
124 | child.top = index - 1;
125 | child.content = `${index}. ${Utils.discardFirstWord(child.content)}`;
126 | }
127 | });
128 | }
129 |
130 | _removeFromBoxChildren(index) {
131 |
132 | const child = this.box.children[index];
133 | const content = child && child.content;
134 |
135 | if (!content) {
136 | return {};
137 | }
138 |
139 | this._discardFromBox(index);
140 | this._orderBoxChildren();
141 | this._focusIndexer.decr();
142 | }
143 |
144 | removeFromQueue({ fromTop } = {}) {
145 |
146 | const index = fromTop ? 1 : this._focusIndexer.get();
147 |
148 | this._removeFromBoxChildren(index);
149 | const [song] = this._removeFromSongs(index);
150 | return song;
151 | }
152 |
153 | _changeOrderInSongs(boxChildrenIndex1, boxChildrenIndex2) {
154 |
155 | const songsArrayIndex1 = this._boxChildrenIndexToSongsIndex(boxChildrenIndex1);
156 | const songaArrayIndex2 = this._boxChildrenIndexToSongsIndex(boxChildrenIndex2);
157 | [
158 | this._songs[songsArrayIndex1], this._songs[songaArrayIndex2]
159 | ] = [
160 | this._songs[songaArrayIndex2], this._songs[songsArrayIndex1]
161 | ];
162 | }
163 |
164 | _changeOrderInBoxChildren(key) {
165 |
166 | const index1 = this._focusIndexer.get();
167 | const child1 = this.box.children[index1];
168 | child1.style.bg = this._bgBlur;
169 |
170 | if (key === keys.MOVE_UP) {
171 | this._focusIndexer.decr();
172 | }
173 | else if (key === keys.MOVE_DOWN) {
174 | this._focusIndexer.incr();
175 | }
176 |
177 | const index2 = this._focusIndexer.get();
178 | const child2 = this.box.children[index2];
179 | child2.style.bg = this._bgFocus;
180 |
181 | [
182 | child1.content,
183 | child2.content
184 | ] = [
185 | `${Utils.getFirstWord(child1.content)} ${Utils.discardFirstWord(child2.content)}`,
186 | `${Utils.getFirstWord(child2.content)} ${Utils.discardFirstWord(child1.content)}`,
187 | ];
188 |
189 | return { index1, index2 };
190 | }
191 |
192 | changeOrderQueue(key) {
193 |
194 | if (this.box.children.length === 1) {
195 | return;
196 | }
197 | const { index1, index2 } = this._changeOrderInBoxChildren(key);
198 | this._changeOrderInSongs(index1, index2);
199 | }
200 | }
201 |
202 | module.exports = Queue;
203 |
--------------------------------------------------------------------------------