├── 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 |
16 | 17 |
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 | ![screenshot](/screenshot.png) 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 | ![screenshot](/screenshot2.png) 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 | --------------------------------------------------------------------------------