├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── api ├── config.js ├── content.js ├── flag.js ├── listen-electron-server.js ├── listen-electron.js ├── listen.js ├── radios.js └── status.js ├── build-electron.sh ├── client ├── .env ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── ab_radio_192.png │ ├── ab_radio_48.png │ ├── ab_radio_512.png │ ├── ab_radio_96.png │ ├── bg.jpg │ ├── debug.html │ ├── favicon.ico │ ├── index.html │ └── manifest.json └── src │ ├── App.css │ ├── App.js │ ├── App.test.js │ ├── BlueButton.js │ ├── Config.js │ ├── Controls.js │ ├── DelaySVG.js │ ├── Flag.js │ ├── Onboarding.js │ ├── Playing.js │ ├── Playlist.js │ ├── SoloMessage.js │ ├── audio.js │ ├── colors.js │ ├── img │ ├── 135894-ads.svg │ ├── CREDITS.md │ ├── a_plus.jpg │ ├── default_radio_logo.svg │ ├── flag2.svg │ ├── flags │ │ ├── Argentina.svg │ │ ├── Belgium.svg │ │ ├── Canada.svg │ │ ├── Finland.svg │ │ ├── France.svg │ │ ├── Germany.svg │ │ ├── Italy.svg │ │ ├── Netherlands.svg │ │ ├── New Zealand.svg │ │ ├── Slovakia.svg │ │ ├── Spain.png │ │ ├── Spain.svg │ │ ├── Switzerland.svg │ │ ├── United Kingdom.svg │ │ ├── United States of America.svg │ │ └── Uruguay.svg │ ├── leap_219178.svg │ ├── leap_219178_ad.svg │ ├── loading.svg │ ├── meter_1697884.svg │ ├── playing.gif │ ├── remove_991614.svg │ ├── round_logo.png │ ├── start_1279169.svg │ └── stop_1279170.svg │ ├── index.css │ └── index.js ├── config └── .gitkeep ├── deploy.sh ├── doc └── abr-buffer.png ├── docker-build.sh ├── docker-run.sh ├── electron.js ├── handlers ├── app.js ├── cache.js ├── config.js └── flag.js ├── index.js ├── log └── .gitkeep ├── model └── .gitkeep ├── package-lock.json ├── package.json └── records └── .gitkeep /.gitignore: -------------------------------------------------------------------------------- 1 | config/available.json 2 | config/radios.json 3 | config/user.json 4 | model/ 5 | records/ 6 | node_modules/ 7 | client/.htpasswd 8 | log/ 9 | error.log 10 | Adblock Radio Buffer-linux-x64/ 11 | .vscode/ 12 | index-linux 13 | index-macos 14 | index-win.exe 15 | 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM adblockradio/adblockradio-docker-primary:0.0.2 2 | 3 | RUN useradd --user-group --create-home --shell /bin/false app && \ 4 | mkdir -p /usr/src/buffer/api \ 5 | /usr/src/buffer/client/build \ 6 | /usr/src/buffer/config \ 7 | /usr/src/buffer/handlers \ 8 | /usr/src/buffer/log \ 9 | /usr/src/buffer/model && \ 10 | chown -R app:app /usr/src/buffer 11 | 12 | WORKDIR /usr/src/buffer 13 | 14 | USER app 15 | 16 | COPY index.js ./ 17 | COPY package* ./ 18 | COPY api/*.js api/ 19 | COPY client/build/ client/build/ 20 | COPY handlers/*.js handlers/ 21 | 22 | RUN npm install --only=prod 23 | 24 | EXPOSE 9820 25 | 26 | CMD ["node", "index.js"] 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Alexandre Storelli 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Adblock Radio Buffer 2 | Listen to the radio ad-free and without interruptions. 3 | Adblock Radio Buffer buffers radio content and lets you fast-forward ads. 4 | 5 | Uses [Adblock Radio](https://github.com/adblockradio/adblockradio) as a backend, featuring machine-learning and acoustic fingerprinting techniques. 6 | 7 | [A technical discussion describes how it works](https://www.adblockradio.com/blog/2018/11/15/designing-audio-ad-block-radio-podcast/). 8 | 9 | ## Preview 10 | 11 | ![](doc/abr-buffer.png) 12 | 13 | ### Player interface 14 | 15 | For each radio, metadata is retrieved with the [open source live metadata scraper](https://github.com/adblockradio/webradio-metadata). 16 | 17 | A colored bar indicates how much of audio is available. Default setting is to buffer up to 15 minutes. 18 | Each color tells the user about the content of the audio: 19 | - blue for music 20 | - green for talk 21 | - red for ads 22 | 23 | ### Select the content you want to hear 24 | 25 | You can choose what kind of content you want to hear or skip on each radio. 26 | 27 | On news stations, it is great just to skip ads. 28 | On musical stations, it's convenient to skip ads and also talk interruptions. 29 | 30 | ### Many radios available, more to come 31 | 32 | At the time of writing, 84 radios are available in the player. 33 | 34 | It is planned to add more. You can [submit requests here](https://github.com/adblockradio/available-models/). 35 | 36 | ### Crowd-sourced improvements of the filters 37 | 38 | Sometimes the predictor is wrong. Not a problem, it is possible to report mispredictions. 39 | 40 | It makes Adblock Radio better for everybody. 41 | 42 | ## Installation 43 | 44 | ### Docker (recommended for most users) 45 | 46 | Choose a directory where configuration and log files will be stored. 47 | ``` 48 | mkdir /var/lib/adblockradio 49 | mkdir /var/lib/adblockradio/config 50 | mkdir /var/lib/adblockradio/log 51 | ``` 52 | 53 | Built images are available on Docker Hub as `adblockradio/buffer`. Check on https://hub.docker.com/r/adblockradio/buffer. 54 | 55 | Find the version you want to run, e.g. `0.1.0`. 56 | 57 | ``` 58 | VERSION=0.1.0 59 | docker pull adblockradio/buffer:$VERSION 60 | cd /var/lib/adblockradio 61 | docker run -it -p 9820:9820 -a STDOUT --mount type=bind,source="$(pwd)"/config,target=/usr/src/adblockradio-buffer/config --mount type=bind,source="$(pwd)"/log,target=/usr/src/adblockradio-buffer/log adblockradio/buffer:$VERSION 62 | ``` 63 | The interface should now be available at http://localhost:9820/. Type Ctrl-P Ctrl-Q to detach from the container. 64 | 65 | To make it accessible to remote clients, configure e.g. Nginx the following way: 66 | ``` 67 | server { 68 | server_name subdomain.your-server.com; 69 | root /usr/share/nginx/html; 70 | 71 | location / { 72 | proxy_set_header X-Real-IP $remote_addr; 73 | proxy_set_header Host $http_host; 74 | proxy_pass http://127.0.0.1:9820; 75 | proxy_set_header X-Forwarded-Proto https; 76 | } 77 | listen 80; 78 | } 79 | ``` 80 | and use [Certbot](https://certbot.eff.org/) to enable HTTPS. You are good to go. 81 | 82 | Advanced users and developers can build their own Docker image with `docker-build.sh`. 83 | 84 | ### Desktop binary (Linux only, alpha quality) 85 | An Electron Linux binary is available [here](http://cdn.s00.adblockradio.com/ABR-Buffer-v1.0.tar.gz). 86 | It has been tested on Debian 8.0/LMDE2 x64. 87 | It needs `ffmpeg` on your system. If you do not have it, run `sudo apt install ffmpeg`. 88 | 89 | Windows and Mac builds are expected in the future. 90 | 91 | ### From source 92 | Installation instructions expect a minimum of technical knowledge. 93 | 94 | Create an empty directory somewhere on your machine, we call it `DIR`. 95 | 96 | First, install [Adblock Radio algorithm](https://github.com/adblockradio/adblockradio). You should now have `DIR/adblockradio` with the files inside. In that subdirectory, run `node demo.js` to check everything is working correctly. 97 | 98 | Then, install this player. Run these commands in `DIR`, so that `DIR/buffer` will be created: 99 | ``` 100 | git clone https://github.com/adblockradio/buffer.git 101 | cd buffer 102 | npm install 103 | cd client 104 | npm install 105 | npm run build 106 | cd .. 107 | ``` 108 | 109 | #### Interface in browser 110 | ``` 111 | npm run start 112 | ``` 113 | Open `http://localhost:9820` in your favorite browser. 114 | Add your radios and enjoy! 115 | 116 | #### Interface in Electron (native desktop app) 117 | ``` 118 | cd node_modules/adblockradio 119 | npm rebuild zeromq --runtime=electron --target=3.1.7 --update-binary 120 | cd ../.. 121 | npm run electron 122 | ``` 123 | Add your radios and enjoy! 124 | 125 | 126 | ### Development 127 | 128 | In a first terminal, open React dev server: 129 | ``` 130 | cd client 131 | npm start 132 | ``` 133 | 134 | #### Interface in browser 135 | In another terminal, run the backend: 136 | ``` 137 | npm run startdev 138 | ``` 139 | Open `http://localhost:9820` in your favorite browser. 140 | 141 | #### Interface in Electron (native desktop app) 142 | In another terminal, run the backend: 143 | ``` 144 | npm run electrondev 145 | ``` 146 | 147 | ## Roadmap 148 | 149 | - Nicer UI and UX (help would be appreciated) 150 | - Cross platform Electron builds 151 | - Docker builds (WIP) 152 | - Cordova apps (WIP), or even better React native apps 153 | 154 | Contributions welcome. 155 | 156 | ## Copyright 157 | 158 | Copyright 2018-2019, [Alexandre Storelli](https://github.com/dest4) 159 | -------------------------------------------------------------------------------- /api/config.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | //const { log } = require('abr-log')('config'); 5 | const { getRadios, getUserConfig } = require('../handlers/config'); 6 | 7 | module.exports = (app) => app.get('/config', function(request, response) { 8 | response.set({ 'Access-Control-Allow-Origin': '*' }); 9 | response.json({ radios: getRadios(), user: getUserConfig() }); 10 | }); -------------------------------------------------------------------------------- /api/content.js: -------------------------------------------------------------------------------- 1 | const { log } = require('abr-log')('content'); 2 | const { toggleContent } = require('../handlers/config'); 3 | 4 | module.exports = (app) => app.put('/config/radios/:country/:name/content/:type/:enable', function(request, response) { 5 | response.set({ 'Access-Control-Allow-Origin': '*' }); 6 | const country = decodeURIComponent(request.params.country); 7 | const name = decodeURIComponent(request.params.name); 8 | const iType = parseInt(decodeURIComponent(request.params.type)); 9 | if (iType < 0 || iType > 1) { 10 | response.writeHead(400); 11 | response.end("err=wrong content type"); 12 | } 13 | const type = ["ads", "speech"][iType]; 14 | const enable = decodeURIComponent(request.params.enable); 15 | toggleContent(country, name, type, enable, function(err) { 16 | if (err) { 17 | log.error("/config/radios/content/" + country + "/" + name + "/" + type + "/" + enable + ": err=" + err); 18 | response.writeHead(400); 19 | response.end("err=" + err); 20 | } else { 21 | response.writeHead(200); 22 | response.end("OK"); 23 | } 24 | }); 25 | }); -------------------------------------------------------------------------------- /api/flag.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | 4 | const { log } = require('abr-log')('flag'); 5 | const flag = require('../handlers/flag'); 6 | 7 | module.exports = (app) => app.post('/flag', async function(request, response) { 8 | const playingRadio = request.body.playingRadio; 9 | const cursors = request.body.cursors; 10 | log.debug("received flag request with playingRadio=" + playingRadio); 11 | response.set({ 'Access-Control-Allow-Origin': '*' }); 12 | try { 13 | const OK = await flag({ playingRadio: playingRadio, cursors: cursors }); 14 | if (!OK) throw new Error("flag request failed"); 15 | response.status(200).send("OK"); 16 | } catch (e) { 17 | log.error("api flag err=" + e); 18 | response.status(400).send("error"); 19 | } 20 | }); -------------------------------------------------------------------------------- /api/listen-electron-server.js: -------------------------------------------------------------------------------- 1 | const { config, getRadio } = require('../handlers/config'); 2 | const cp = require("child_process"); 3 | const Speaker = require("speaker"); 4 | const { log } = require("abr-log")("listen-electron"); 5 | 6 | const speaker = new Speaker({ 7 | channels: 2, // 2 channels 8 | bitDepth: 16, // 16-bit samples 9 | sampleRate: 44100 // 44,100 Hz sample rate 10 | }); 11 | 12 | const bitrate = 44100 * 2 * 2; // 44100 Hz, stereo, 16 bit 13 | 14 | speaker.on("error", function(err) { 15 | log.warn("speaker err=" + err); 16 | }); 17 | 18 | let speakerOpened = false; 19 | speaker.once("open", function() { 20 | log.debug("speaker opened in main()"); 21 | speakerOpened = true; 22 | }); 23 | 24 | 25 | let volume = 0; 26 | let transcoder, listenTimer; 27 | 28 | // play audio locally 29 | function play(radio, delay) { 30 | try { 31 | log.info("play " + radio + " at delay " + delay); 32 | 33 | const radioObj = getRadio(...radio.split("_")); 34 | if (!radioObj) { 35 | return "radio not found"; 36 | } 37 | 38 | let skipPcmBytes = Math.max(config.user.streamInitialBuffer - 0.5) * 44100 * 2 * 2; // 44100 Hz, stereo, 16 bit. 39 | 40 | var initialBuffer = radioObj.liveStatus.audioCache.readLast(+delay+config.user.streamInitialBuffer,config.user.streamInitialBuffer); 41 | //log.debug("listen: readCursor set to " + radioObj.liveStatus.audioCache.readCursor); 42 | 43 | if (!initialBuffer) { 44 | log.error("/listen/" + radio + "/" + delay + ": initialBuffer not available"); 45 | return "buffer not available"; 46 | } 47 | 48 | stop(); // shut down previous trancoder 49 | 50 | transcoder.stdout.on("data", function(data) { 51 | if (data.length && skipPcmBytes > 0) { 52 | skipPcmBytes -= data.length; 53 | log.debug("skipPcmBytes " + (skipPcmBytes + data.length) + " => " + skipPcmBytes); 54 | if (skipPcmBytes < 0) { 55 | data = data.slice(data.length + skipPcmBytes); 56 | } else { 57 | return; 58 | } 59 | } 60 | if (!data.length) { 61 | return log.warn("transcoder returned empty data"); 62 | } 63 | transcoder.stdout.pause(); 64 | for (let i=0; i app.get('/listen/:radio/:delay', function(request, response) { 29 | const radio = decodeURIComponent(request.params.radio); 30 | const delay = request.params.delay; 31 | 32 | const radioObj = getRadio(...radio.split("_")); 33 | if (!radioObj) { 34 | response.writeHead(400); 35 | return response.end("radio not found"); 36 | } 37 | 38 | var state = { requestDate: new Date() }; //newRequest: true, 39 | var queryRandomNum = request.query.t; 40 | if (lastQueryRandomNum !== null && queryRandomNum == lastQueryRandomNum) { 41 | log.warn("listen: discarding second listen request with same query string"); 42 | response.writeHead(400); 43 | return response.end("query string must change at every request"); 44 | } 45 | listenRequestDate = state.requestDate; 46 | lastQueryRandomNum = queryRandomNum; 47 | 48 | var initialBuffer = radioObj.liveStatus.audioCache.readLast(+delay+config.user.streamInitialBuffer,config.user.streamInitialBuffer); 49 | //log.debug("listen: readCursor set to " + radioObj.liveStatus.audioCache.readCursor); 50 | 51 | if (!initialBuffer) { 52 | log.error("/listen/" + radio + "/" + delay + ": initialBuffer not available"); 53 | response.writeHead(400); 54 | return response.end("buffer not available"); 55 | } 56 | 57 | log.info("listen: send initial buffer of " + initialBuffer.length + " bytes (" + getDeviceInfoExpress(request) + ")"); 58 | 59 | switch(radioObj.codec) { 60 | case "AAC": response.set('Content-Type', 'audio/aacp'); break; 61 | case "MP3": response.set('Content-Type', 'audio/mpeg'); break; 62 | default: log.warn("unsupported codec: " + radioObj.codec); 63 | } 64 | 65 | response.write(initialBuffer); 66 | 67 | var finish = function() { 68 | clearInterval(listenTimer); 69 | response.end(); 70 | } 71 | 72 | var listenTimer = setInterval(function() { 73 | var willWaitDrain = !response.write(""); 74 | if (!willWaitDrain) { // detect congestion of stream 75 | sendMore(); 76 | } else { 77 | log.debug("listenHandler: will wait for drain event"); 78 | 79 | var drainCallback = function() { 80 | clearTimeout(timeoutMonitor); 81 | sendMore(); 82 | } 83 | response.once("drain", drainCallback); 84 | var timeoutMonitor = setTimeout(function() { 85 | response.removeListener("drain", drainCallback); 86 | log.error("listenHandler: drain event not emitted, connection timeout"); 87 | return finish(); 88 | }, config.user.streamGranularity*1500); 89 | } 90 | }, 1000*config.user.streamGranularity); 91 | 92 | var sendMore = function() { 93 | if (listenRequestDate !== state.requestDate) { 94 | log.warn("request canceled because another one has been initiated"); 95 | return finish(); 96 | } 97 | var radioObj = getRadio(...radio.split("_")); 98 | if (!radioObj) { 99 | log.error("/listen/" + radio + "/" + delay + ": radio not available"); 100 | return finish(); 101 | } 102 | var audioCache = radioObj.liveStatus.audioCache; 103 | if (!audioCache) { 104 | log.error("/listen/" + radio + "/" + delay + ": audioCache not available"); 105 | return finish(); 106 | } 107 | var prevReadCursor = audioCache.readCursor; 108 | response.write(audioCache.readAmountAfterCursor(config.user.streamGranularity)); 109 | //log.debug("listen: readCursor date=" + state.requestDate + " : " + prevReadCursor + " => " + audioCache.readCursor); 110 | } 111 | }); -------------------------------------------------------------------------------- /api/radios.js: -------------------------------------------------------------------------------- 1 | const { log } = require('abr-log')('radios'); 2 | const { insertRadio, removeRadio, getAvailableInactive, config } = require('../handlers/config'); 3 | 4 | const insertRadioRoute = (app) => app.put('/config/radios/:country/:name', function(request, response) { 5 | response.set({ 'Access-Control-Allow-Origin': '*' }); 6 | var country = decodeURIComponent(request.params.country); 7 | var name = decodeURIComponent(request.params.name); 8 | insertRadio(country, name, function(err) { 9 | if (err) { 10 | log.error("/config/insert/" + country + "/" + name + ": err=" + err); 11 | response.writeHead(400); 12 | response.end("err=" + err); 13 | } else { 14 | response.writeHead(200); 15 | response.end("OK"); 16 | } 17 | }); 18 | }); 19 | 20 | const removeRadioRoute = (app) => app.delete('/config/radios/:country/:name', function(request, response) { 21 | response.set({ 'Access-Control-Allow-Origin': '*' }); 22 | var country = decodeURIComponent(request.params.country); 23 | var name = decodeURIComponent(request.params.name); 24 | removeRadio(country, name, function(err) { 25 | if (err) { 26 | log.error("/config/remove/" + country + "/" + name + ": err=" + err); 27 | response.writeHead(400); 28 | response.end("err=" + err); 29 | } else { 30 | response.writeHead(200); 31 | response.end("OK"); 32 | } 33 | }); 34 | }); 35 | 36 | const getAvailableRadios = (app) => app.get('/config/radios/available', function(request, response) { 37 | response.set({ 'Access-Control-Allow-Origin': '*' }); 38 | response.json(getAvailableInactive()); 39 | }); 40 | 41 | module.exports = function(app) { 42 | insertRadioRoute(app); 43 | removeRadioRoute(app); 44 | getAvailableRadios(app); 45 | } -------------------------------------------------------------------------------- /api/status.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { gatherStatus } = require('../handlers/config'); 4 | 5 | module.exports = (app) => app.get('/status/:since', function(request, response) { 6 | const since = request.params.since; 7 | response.set({ 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-cache, must-revalidate' }); 8 | response.json(gatherStatus(since)); 9 | }); -------------------------------------------------------------------------------- /build-electron.sh: -------------------------------------------------------------------------------- 1 | export npm_config_target=3.0.9 2 | export npm_config_arch=x64 3 | export npm_config_target_arch=x64 4 | export npm_config_disturl=https://atom.io/download/electron 5 | export npm_config_disturl=https://atom.io/download/electron 6 | export npm_config_build_from_source=true 7 | 8 | #cd ../adblockradio 9 | cd node_modules/adblockradio 10 | npm rebuild zeromq --runtime=electron --target=3.0.9 --update-binary 11 | 12 | #cd ../adblockradio-buffer 13 | cd ../.. 14 | HOME=~/.electron-gyp npm install 15 | 16 | #ln -s ../adblockradio . 17 | 18 | cd client 19 | npm run build 20 | 21 | cd .. 22 | #npx electron-packager electron.js --overwrite 23 | 24 | npx electron-packager . --overwrite \ 25 | --platform=linux \ 26 | --arch=x64 \ 27 | --icon=resources/app/client/src/img/ab_radio_512.png \ 28 | --ignore=model/ \ 29 | --ignore=doc/ \ 30 | --ignore=config/available.json \ 31 | --ignore=config/radios.json \ 32 | --ignore=config/user.json \ 33 | --ignore=records/ \ 34 | --ignore=index-linux \ 35 | --ignore=index-macos \ 36 | --ignore=index-win.exe \ 37 | --ignore=node_modules/adblockradio/demo \ 38 | --ignore=node_modules/adblockradio/demo-linux \ 39 | --ignore=node_modules/adblockradio/demo-win \ 40 | --ignore=node_modules/adblockradio/demo-macos \ 41 | --ignore=node_modules/adblockradio/post-processing$ \ 42 | --ignore=client/node_modules \ 43 | --ignore=client/src \ 44 | --ignore=client/public \ 45 | --ignore=client/log \ 46 | --ignore=node_modules/\.cache \ 47 | --ignore=node_modules/adblockradio/log/ \ 48 | --ignore=node_modules/adblockradio/model/ \ 49 | --ignore=node_modules/adblockradio/records/ \ 50 | --ignore=node_modules/adblockradio/podcasts/ \ 51 | --ignore=node_modules/adblockradio-buffer-linux-x64/ \ 52 | --ignore=.vscode/ \ 53 | --ignore=error\.log 54 | 55 | # TODO missing ignore for log/ (as is, it also ignores abr-log and it's a no-go) -------------------------------------------------------------------------------- /client/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | This demo webapp has been bootstrapped with [Create React App](https://github.com/facebookincubator/create-react-app). 2 | 3 | ## Usage 4 | 5 | ```sh 6 | npm install 7 | ``` 8 | 9 | ```sh 10 | npm start 11 | ``` 12 | 13 | If not done automatically, open in your browser the url: 14 | `http://localhost:3000` 15 | 16 | ## Preview 17 | 18 | ![Snapshot](web-interface.png) 19 | 20 | ## License 21 | 22 | See root license of this repository 23 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "adblockradio-buffer-client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "async": "^2.6.2", 7 | "classnames": "^2.2.6", 8 | "moment": "^2.24.0", 9 | "rc-checkbox": "^2.1.6", 10 | "react": "^16.8.5", 11 | "react-dom": "^16.8.5", 12 | "styled-components": "^2.4.1" 13 | }, 14 | "devDependencies": { 15 | "react-scripts": "^2.1.8" 16 | }, 17 | "scripts": { 18 | "start": "react-scripts start", 19 | "build": "react-scripts build", 20 | "test": "react-scripts test --env=jsdom", 21 | "eject": "react-scripts eject" 22 | }, 23 | "homepage": "./", 24 | "browserslist": [ 25 | ">0.2%", 26 | "not dead", 27 | "not ie <= 11", 28 | "not op_mini all" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /client/public/ab_radio_192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adblockradio/buffer/c7e20e93a9476af1d407099c0dc63cca1ef8e11a/client/public/ab_radio_192.png -------------------------------------------------------------------------------- /client/public/ab_radio_48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adblockradio/buffer/c7e20e93a9476af1d407099c0dc63cca1ef8e11a/client/public/ab_radio_48.png -------------------------------------------------------------------------------- /client/public/ab_radio_512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adblockradio/buffer/c7e20e93a9476af1d407099c0dc63cca1ef8e11a/client/public/ab_radio_512.png -------------------------------------------------------------------------------- /client/public/ab_radio_96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adblockradio/buffer/c7e20e93a9476af1d407099c0dc63cca1ef8e11a/client/public/ab_radio_96.png -------------------------------------------------------------------------------- /client/public/bg.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adblockradio/buffer/c7e20e93a9476af1d407099c0dc63cca1ef8e11a/client/public/bg.jpg -------------------------------------------------------------------------------- /client/public/debug.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Media debug page 4 | 5 | TEST 6 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adblockradio/buffer/c7e20e93a9476af1d407099c0dc63cca1ef8e11a/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Adblock Radio 11 | 12 | 13 | 16 | 29 |
30 | 31 | 32 | -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "Adblock Radio Buffer", 3 | "name": "Adblock Radio Buffer", 4 | "icons": [ 5 | { 6 | "src": "ab_radio_48.png", 7 | "type": "image/png", 8 | "sizes": "48x48" 9 | }, 10 | { 11 | "src": "ab_radio_96.png", 12 | "type": "image/png", 13 | "sizes": "96x96" 14 | }, 15 | { 16 | "src": "ab_radio_192.png", 17 | "type": "image/png", 18 | "sizes": "192x192" 19 | }, 20 | { 21 | "src": "ab_radio_512.png", 22 | "type": "image/png", 23 | "sizes": "512x512" 24 | } 25 | ], 26 | "start_url": "./index.html", 27 | "display": "standalone", 28 | "background_color": "#98b3ff", 29 | "theme_color": "#98b3ff", 30 | "orientation": "portrait" 31 | } 32 | -------------------------------------------------------------------------------- /client/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 80px; 7 | } 8 | 9 | .App-header { 10 | background-color: #222; 11 | height: 150px; 12 | padding: 20px; 13 | color: white; 14 | } 15 | 16 | .App-title { 17 | font-size: 1.5em; 18 | } 19 | 20 | .App-intro { 21 | font-size: large; 22 | } 23 | 24 | .Footer { 25 | text-align: center; 26 | margin: 30px 15px; 27 | } 28 | -------------------------------------------------------------------------------- /client/src/App.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Alexandre Storelli 2 | 3 | import React, { Component } from 'react'; 4 | import './App.css'; 5 | import Onboarding from './Onboarding.js'; 6 | import Config from './Config.js'; 7 | import Playlist from './Playlist.js'; 8 | import Playing from './Playing.js'; 9 | import Controls from './Controls.js'; 10 | import SoloMessage from './SoloMessage'; 11 | 12 | import { play, stop, setVolume } from './audio.js'; 13 | import styled from "styled-components"; 14 | import classNames from 'classnames'; 15 | import async from 'async'; 16 | 17 | /* global cordova */ 18 | /* global Android */ 19 | 20 | const DELAYS = { 21 | FETCH_UPDATES_PLAYING: 1000, 22 | FETCH_UPDATES_IDLE: 1000, // if higher than 10, need to update value in load.js 23 | VISUALS_ACTIVE: 1000, 24 | VISUALS_HIDDEN: 10000 25 | } 26 | 27 | const VOLUMES = { 28 | MUTED: 0.1, 29 | DEFAULT: 0.5 30 | } 31 | 32 | const VIEWS = { 33 | LOADING: 100, 34 | ONBOARDING: 101, 35 | PLAYLIST: 102, 36 | CONFIG: 103, 37 | PLAYER: 104, 38 | } 39 | 40 | class App extends Component { 41 | constructor(props) { 42 | super(props); 43 | const isElectron = navigator.userAgent.toLowerCase().indexOf(' electron/') > -1; // in a Electron environment (https://github.com/electron/electron/issues/2288) 44 | this.state = { 45 | configLoaded: false, 46 | configError: false, 47 | config: [], 48 | playingRadio: null, 49 | playingDelay: null, 50 | clockDiff: 0, 51 | view: VIEWS.LOADING, 52 | locale: "fr", 53 | stopUpdates: false, 54 | communicationError: false, 55 | //doVisualUpdates: true, 56 | isElectron: isElectron, 57 | isCordovaApp: document.URL.indexOf('http://') === -1 && document.URL.indexOf('https://') === -1 && !isElectron, 58 | isAndroidApp: navigator.userAgent === "abr_android" 59 | } 60 | this.play = this.play.bind(this); 61 | this.refreshStatusContainer = this.refreshStatusContainer.bind(this); 62 | this.refreshConfig = this.refreshConfig.bind(this); 63 | this.tick = this.tick.bind(this); 64 | this.insertRadio = this.insertRadio.bind(this); 65 | this.removeRadio = this.removeRadio.bind(this); 66 | this.toggleContent = this.toggleContent.bind(this); 67 | this.setLocale = this.setLocale.bind(this); 68 | this.getCurrentMetaForRadio = this.getCurrentMetaForRadio.bind(this); 69 | this.flagContent = this.flagContent.bind(this); 70 | } 71 | 72 | componentDidMount() { 73 | var self = this; 74 | if (this.state.isElectron) { 75 | console.log("detected Electron environment"); 76 | } else if (this.state.isCordovaApp) { 77 | console.log("detected Cordova environment"); 78 | // https://stackoverflow.com/questions/950087/how-do-i-include-a-javascript-file-in-another-javascript-file 79 | const head = document.getElementsByTagName('head')[0]; 80 | const script = document.createElement('script'); 81 | script.type = 'text/javascript'; 82 | script.src = "./cordova.js"; 83 | const callback = function() { 84 | console.log("cordova script loaded"); 85 | } 86 | script.onreadystatechange = callback; 87 | script.onload = callback; 88 | head.appendChild(script); 89 | } else if (this.state.isAndroidApp) { 90 | console.log("detected Android environment"); 91 | } else { 92 | console.log("detected web environment"); 93 | } 94 | this.refreshConfig(function() { 95 | if (self.state.config.radios && self.state.config.radios.length === 0) { 96 | self.setState({ view: VIEWS.ONBOARDING }); 97 | } else { 98 | self.setState({ view: VIEWS.PLAYER }); 99 | } 100 | }); 101 | this.newTickInterval(DELAYS.VISUALS_ACTIVE); 102 | 103 | document.addEventListener('visibilitychange', function() { 104 | //self.setState({ doVisualUpdates: !document.hidden }); 105 | console.log("visibilitychange: visible=" + !document.hidden); 106 | self.newTickInterval(document.hidden ? DELAYS.VISUALS_HIDDEN : DELAYS.VISUALS_ACTIVE); 107 | }); 108 | 109 | var onHop = function (notification, eopts) { 110 | if (self.state.config.radios.length > 1) { 111 | var index = self.getRadioIndex(self.state.playingRadio); 112 | var newIndex = (index + 1) % self.state.config.radios.length; 113 | var newRadio = self.state.config.radios[newIndex].country + "_" + self.state.config.radios[newIndex].name; 114 | self.play(newRadio, null, null); 115 | console.log("notification: next channel"); 116 | } else { 117 | console.log("notification: next channel but not possible"); 118 | } 119 | }; 120 | 121 | var onStop = function (notification, eopts) { 122 | console.log("notification: stop playback"); 123 | self.play(null, null, null); 124 | }; 125 | 126 | if (this.state.isCordovaApp) { // set up notifications actions callbacks 127 | document.addEventListener("deviceready", function(){ 128 | self.setState({ isCordovaDeviceReady: true }); 129 | cordova.plugins.notification.local.on('hop', onHop); 130 | cordova.plugins.notification.local.on('stop', onStop); 131 | }); 132 | } else if (this.state.isAndroidApp) { 133 | window.notificationHop = onHop; 134 | window.notificationHop = window.notificationHop.bind(this); 135 | window.notificationStop = onStop; 136 | window.notificationStop = window.notificationStop.bind(this); 137 | } 138 | } 139 | 140 | componentWillUnmount() { 141 | clearInterval(this.timerID); 142 | this.newRefreshStatusInterval(0); 143 | } 144 | 145 | newRefreshStatusInterval(interval, requestFullDataAtOnce) { 146 | if (this.metadataTimerID) clearInterval(this.metadataTimerID); 147 | if (interval > 0) { 148 | this.metadataTimerID = setInterval(() => this.refreshStatusContainer({ requestFullData: false}), interval); 149 | this.refreshStatusContainer({ requestFullData: requestFullDataAtOnce }); 150 | } 151 | } 152 | 153 | async refreshStatus(requestFullData) { 154 | const since = requestFullData ? this.state.config.user.cacheLen : Math.round(Math.max(DELAYS.FETCH_UPDATES_IDLE, DELAYS.FETCH_UPDATES_PLAYING)/1000 + 5); 155 | if (!this.state.isElectron) { 156 | try { 157 | const request = await fetch("status/" + since + "?t=" + Math.round(Math.random()*1000000)); 158 | const res = await request.text(); 159 | return JSON.parse(res); 160 | } catch (e) { 161 | console.log("refreshStatus: could not load status update for radios. err=" + e); 162 | return null; 163 | } 164 | } else { 165 | return navigator.abrserver.gatherStatus(since); 166 | } 167 | } 168 | 169 | async refreshStatusContainer(options) { 170 | if (this.state.stopUpdates) return; 171 | 172 | //console.log("refresh status"); 173 | var self = this; 174 | const resParsed = await this.refreshStatus(options.requestFullData); 175 | if (!resParsed) { 176 | this.play(null, null, function() {}); 177 | return this.setState({ communicationError: true }); 178 | } 179 | 180 | var stateChange = { communicationError: false }; 181 | var types = ["class", "metadata", "volume"]; 182 | for (var i=0; i=0; itS--) { 206 | if (stateChange[rt][itS].validTo && stateChange[rt][itS].validTo < +self.state.date - self.state.config.user.cacheLen*1000) { 207 | //if (types[j] === "class") console.log("refreshStatus: " + rt + " remove old item validTo=" + stateChange[rt][itS].validTo); 208 | stateChange[rt].splice(itS, 1); // remove old elements 209 | } else if (tO[itO].validFrom === stateChange[rt][itS].validFrom) { 210 | alreadyThere = true; 211 | break; 212 | } 213 | } 214 | if (alreadyThere && tO[itO].validTo !== null && stateChange[rt][itS].validTo === null) { // we overwrite the last element, because validTo was erased 215 | //if (types[j] === "class") console.log("refreshStatus: " + rt + " overwrite validFrom=" + tO[itO].validFrom); 216 | stateChange[rt][itS] = tO[itO]; 217 | } else if (!alreadyThere) { 218 | //if (types[j] === "class") console.log("refreshStatus: " + rt + " unshift validFrom=" + tO[itO].validFrom); 219 | stateChange[rt].unshift(tO[itO]); 220 | } 221 | } 222 | //stateChange[radio + "|" + types[j]] = tO.reverse(); 223 | } 224 | } 225 | await this.setStateAsync(stateChange); 226 | this.showNotification(); 227 | } 228 | 229 | async setStateAsync(state) { 230 | return new Promise((resolve) => { 231 | this.setState(state, resolve); 232 | }); 233 | } 234 | 235 | async refreshConfig(callback) { 236 | try { 237 | if (!this.state.isElectron) { 238 | const request = await fetch("config?t=" + Math.round(Math.random()*1000000)); 239 | const res = await request.text(); 240 | var config = JSON.parse(res); 241 | } else { 242 | /*await new Promise((resolve) => { 243 | ipcRenderer.send('config', ''); 244 | ipcRenderer.once('config', (event, arg) => { 245 | console.log(arg) // prints "pong" 246 | config = JSON.parse(arg); 247 | resolve(); 248 | }); 249 | });*/ 250 | config = { radios: navigator.abrserver.getRadios(), user: navigator.abrserver.getUserConfig() }; 251 | } 252 | await this.setState({ config: config, configError: false, configLoaded: true }); 253 | this.newRefreshStatusInterval(DELAYS.FETCH_UPDATES_IDLE, true); 254 | } catch (e) { 255 | console.log("problem refreshing config from server: " + e); 256 | this.setState({ configError: true, configLoaded: true }); 257 | clearInterval(this.timerID); 258 | clearInterval(this.metadataTimerID); 259 | } 260 | if (callback) callback(); 261 | } 262 | 263 | 264 | newTickInterval(interval) { 265 | if (this.timerID) clearInterval(this.timerID); 266 | if (interval > 0) { 267 | this.timerID = setInterval(this.tick, interval); 268 | this.tick(); 269 | } 270 | } 271 | 272 | tick() { 273 | if (this.state.stopUpdates) return; 274 | var self = this; 275 | this.setState({ date: new Date() }, function() { 276 | if (!this.state.config || !this.state.config.radios) return; 277 | var acceptableContent = new Array(this.state.config.radios.length); 278 | async.forEachOf(this.state.config.radios, function(radio, iRadio, onCursorChecked) { 279 | var radioName = radio.country + "_" + radio.name; 280 | self.checkCursor(radioName, function(res) { 281 | //res = { err: ... , delayChanged: ..., hasAcceptableContent: ... } 282 | acceptableContent[iRadio] = res.hasAcceptableContent; 283 | if (self.state.playingRadio === radioName && res.hasAcceptableContent && !res.delayChanged) { 284 | self.setVolumeForRadio(radioName, VOLUMES.DEFAULT); 285 | onCursorChecked(); 286 | } else if (self.state.playingRadio === radioName && res.hasAcceptableContent && res.delayChanged) { 287 | // here, we know we are playing a channel with good content at the updated delay 288 | self.play(radioName, +self.state.date - self.state[radioName + "|cursor"], function(err) { 289 | onCursorChecked(); 290 | }); 291 | } else { 292 | onCursorChecked(); 293 | } 294 | }); 295 | }, function(err) { 296 | if (err) console.log("tick err=" + err); 297 | var iPlayingRadio = self.getRadioIndex(self.state.playingRadio); 298 | // here, all cursors have been updated 299 | //console.log("iPlayingRadio=" + iPlayingRadio + " acceptable=" + acceptableContent[iPlayingRadio]); 300 | if (iPlayingRadio < 0 || acceptableContent[iPlayingRadio]) return; 301 | // here, we need to change channel. 302 | console.log("need to change channel") 303 | var listRadiosToTry = []; 304 | for (var i=iPlayingRadio + 1; i=0; i--) { 370 | if (classes[i].validFrom <= playingEpoch && (!classes[i].validTo || playingEpoch < classes[i].validTo)) { 371 | iCurrentClass = i; 372 | break; 373 | } 374 | } 375 | /*if (iCurrentClass < 0 ) { 376 | if (DEBUG) console.log("there are no metadata available for the given position."); 377 | return callback({ err: "no metadata", delayChanged: false, hasAcceptableContent: null }); 378 | iCurrentClass = classes.length-1; 379 | // there are no metadata available for the current playing position. 380 | }*/ 381 | 382 | if (iCurrentClass >= 0 && !this.acceptableContent(iRadio, classes[iCurrentClass])) { 383 | // that radio has bad content, we move the cursor forward until we find a good content. 384 | // we scan classes at later times, i.e. at lower indexes. 385 | var iTargetClass = -1; 386 | for (i=iCurrentClass; i>=0; i--) { 387 | if (this.acceptableContent(iRadio, classes[i]) 388 | && (!classes[i].validTo || classes[i].validTo - classes[i].validFrom > this.state.config.user.discardSmallSegments*1000)) { 389 | iTargetClass = i; 390 | break; 391 | } 392 | } 393 | 394 | if (iTargetClass >= 0) { 395 | var delay = Math.floor((+this.state.date - classes[iTargetClass].validFrom)/1000)*1000; 396 | //var delay1 = +this.state.date - this.state[radio + "|lastPlayedDate"]; 397 | //var delay = Math.max(delay0, delay1); 398 | if (DEBUG) console.log("fast forward to delay " + delay); 399 | stateChange[radio + "|cursor"] = +this.state.date - delay; 400 | return this.setState(stateChange, function() { 401 | callback({ err: null, delayChanged: true, hasAcceptableContent: true }); 402 | }); 403 | } 404 | 405 | // no acceptable audio after 406 | stateChange[radio + "|cursor"] = +this.state.date; 407 | this.setState(stateChange, function() { 408 | callback({ err: null, delayChanged: true, hasAcceptableContent: false }); 409 | }); 410 | 411 | } else if (this.state.playingRadio !== radio && this.state[radio + "|cursor"] < +this.state.date - this.state.config.user.cacheLen*2/3*1000) { 412 | // not played radios must not have their cursor go too far backwards 413 | stateChange[radio + "|cursor"] = +this.state.date - this.state.config.user.cacheLen*2/3*1000; 414 | return this.setState(stateChange, function() { 415 | callback({ err: null, delayChanged: true, hasAcceptableContent: true }); 416 | }); 417 | } else if (this.state.playingRadio === radio) { 418 | // played radio must have a constant delay, so we need to push its cursor regularly 419 | stateChange[this.state.playingRadio + "|cursor"] = +this.state.date - this.state.playingDelay; 420 | return this.setState(stateChange, function() { 421 | callback({ err: null, delayChanged: false, hasAcceptableContent: true }); 422 | }); 423 | } else { 424 | return callback({ err: null, delayChanged: false, hasAcceptableContent: true }); 425 | } 426 | } 427 | 428 | defaultDelay(radio) { 429 | var delays = [(+this.state[radio + "|available"])*1000, this.state.config.user.cacheLen*1000*2/3]; 430 | var classes = this.state[radio + "|class"]; 431 | if (classes) { 432 | var firstMetaDate = classes[classes.length-1].validFrom; 433 | delays.push(+this.state.date-firstMetaDate); 434 | } 435 | return Math.min(...delays); 436 | } 437 | 438 | getRadioIndex(radio) { 439 | for (var i=0; i 0) { 451 | targetVolume = Math.pow(10, (Math.min(70-this.state[radio + "|volume"][0].payload,0))/20) 452 | } 453 | setVolume(targetVolume); 454 | } 455 | 456 | getCurrentMetaForRadio(radio) { 457 | var liveMetadata; 458 | var metaList = this.state[radio + "|metadata"]; 459 | if (metaList) { 460 | var targetDate = this.state[radio + "|cursor"]; 461 | for (let j=0; j 1) { 510 | actions.unshift({ id: 'hop', title: { en: "Change channel", fr: "Changer de station"}[lang] }); 511 | } 512 | cordova.plugins.notification.local.schedule({ 513 | id: 1, 514 | title: notifTitle, 515 | text: notifText, 516 | actions: actions, 517 | sticky: true, 518 | led: false, 519 | sound: false, 520 | wakeup: false 521 | }); 522 | } 523 | }); 524 | 525 | } else { 526 | cordova.plugins.notification.local.clearAll(function() { 527 | console.log("notification: clear all"); 528 | }, this); 529 | } 530 | } 531 | 532 | androidNotification() { 533 | if (this.state.playingRadio) { 534 | var index = this.getRadioIndex(this.state.playingRadio); 535 | var name = this.state.config.radios[index].name; 536 | var lang = this.state.locale; 537 | var meta = this.getCurrentMetaForRadio(this.state.playingRadio); 538 | var hasMeta = !!meta.text; 539 | var notifTitle = hasMeta ? name : 'Adblock Radio'; 540 | var notifText = hasMeta ? meta.text : name; 541 | var actions = [ { id: 'stop', title: { en: "Stop", fr: "Stop" }[lang] } ]; 542 | if (this.state.config.radios.length > 1) { 543 | actions.unshift({ id: 'hop', title: { en: "Change channel", fr: "Changer de station"}[lang] }); 544 | } 545 | Android.showNotification(notifTitle, notifText, actions); 546 | } else { 547 | Android.clearNotification(); 548 | } 549 | } 550 | 551 | play(radio, delay, callback) { 552 | // play radio. params: 553 | // radio: if present, play that radio, otherwise stop 554 | // delay: if present, play at that delay. otherwise, play at current cursor position. 555 | // callback: optional 556 | 557 | const maxDelay = 1000 * (Math.min(this.state.config.user.cacheLen, +this.state[radio + "|available"]));// - this.state.config.user.streamInitialBuffer); 558 | console.log("play radio=" + radio + " delay=" + delay + " maxDelay=" + maxDelay); 559 | 560 | if (radio && maxDelay > 0 && !(delay === null && radio === this.state.playingRadio)) { 561 | radio = radio || this.state.playingRadio; 562 | if (delay === null || delay === undefined || isNaN(delay)) { // delay == 0 is a valid delay. 563 | delay = +this.state.date - this.state[radio + "|cursor"]; 564 | } 565 | 566 | if (delay < 0) delay = 0; 567 | if (delay > maxDelay) delay = maxDelay; 568 | 569 | delay = Math.round(delay/1000)*1000; // rounded seconds 570 | 571 | console.log("Play: radio=" + radio + " delay=" + delay + "ms"); 572 | 573 | var stateChange = {}; 574 | stateChange[radio + "|cursor"] = +this.state.date - delay; 575 | stateChange["playingRadio"] = radio; 576 | stateChange["playingDelay"] = delay; 577 | this.setState(stateChange, function() { 578 | this.showNotification(); 579 | }); 580 | 581 | document.title = radio.split("_")[1] + " - Adblock Radio"; 582 | const url = "listen/" + encodeURIComponent(radio) + "/" + (delay/1000) + "?t=" + Math.round(Math.random()*1000000000); 583 | play(url, function(err) { 584 | if (err) console.log("Play: error=" + err); 585 | if (callback) callback(err); 586 | }); 587 | this.setVolumeForRadio(radio, VOLUMES.DEFAULT); 588 | this.newRefreshStatusInterval(DELAYS.FETCH_UPDATES_PLAYING, false); 589 | 590 | } else { 591 | this.setState({ 592 | playingRadio: null, 593 | playingDelay: null 594 | }, function() { 595 | this.showNotification(); 596 | }); 597 | document.title = "Adblock Radio"; 598 | stop(); 599 | this.newRefreshStatusInterval(DELAYS.FETCH_UPDATES_IDLE, false); 600 | 601 | if (callback) callback(null); 602 | } 603 | } 604 | 605 | setLocale(lang) { 606 | this.setState({ locale: lang }); 607 | } 608 | 609 | async insertRadio(country, name) { 610 | try { 611 | if (!this.state.isElectron) { 612 | await fetch("config/radios/" + encodeURIComponent(country) + "/" + encodeURIComponent(name) + "?t=" + Math.round(Math.random()*1000000), { method: "PUT" }); 613 | } else { 614 | await new Promise((resolve) => { navigator.abrserver.insertRadio(country, name, resolve) }); 615 | } 616 | await this.refreshConfig(); 617 | } catch (e) { 618 | console.log("could not insert radio " + country + "_" + name + ". err=" + e); 619 | } 620 | } 621 | 622 | async removeRadio(country, name) { 623 | if (this.state.playingRadio === country + "_" + name) this.play(null, null, function() {}); 624 | try { 625 | if (!this.state.isElectron) { 626 | await fetch("config/radios/" + encodeURIComponent(country) + "/" + encodeURIComponent(name) + "?t=" + Math.round(Math.random()*1000000), { method: "DELETE" }); 627 | } else { 628 | await new Promise((resolve) => { navigator.abrserver.removeRadio(country, name, resolve) }); 629 | } 630 | await this.refreshConfig(); 631 | } catch (e) { 632 | console.log("could not remove radio " + country + "_" + name + ". err=" + e); 633 | } 634 | } 635 | 636 | async toggleContent(country, name, contentType, enabled) { 637 | try { 638 | // /config/radios/:country/:name/content/:type/:enable 639 | // Note: */ads/* is blocked by browser ad blocker, hence the indexOf(...). 640 | if (!this.state.isElectron) { 641 | await fetch("config/radios/" + encodeURIComponent(country) + "/" + encodeURIComponent(name) + "/content/" + 642 | ["ads", "speech"].indexOf(contentType) + "/" + (enabled ? "enable" : "disable") + "?t=" + Math.round(Math.random()*1000000), { method: "PUT" }); 643 | } else { 644 | await new Promise((resolve) => { navigator.abrserver.toggleContent(country, name, contentType, (enabled ? "enable" : "disable"), resolve); }); 645 | } 646 | await this.refreshConfig(); 647 | } catch (e) { 648 | console.log("could not toggle content for radio " + country + "_" + name + " content=" + contentType + " enabled=" + enabled + " err=" + e); 649 | } 650 | } 651 | 652 | async flagContent() { 653 | console.log("will submit flag"); 654 | const cursors = {}; 655 | for (let i=0; i 686 |

{{ en: "Loading…", fr: "Chargement…" }[lang]}

687 | 688 | ); 689 | } else if (this.state.configError) { 690 | return ( 691 | 692 |

{{ en: "Oops, could not connect to server :(", fr: "Oops, problème de connexion au serveur :(" }[lang]}

693 |

{{ en: "Check the server is running then reload this page", fr: "Vérifiez que le serveur est toujours actif puis rechargez cette page" }[lang]}

694 |
695 | ) 696 | } 697 | 698 | //console.log("Metadata props: date=" + (+this.state.date) + " clockDiff=" + this.state.clockDiff + " playingDelay=" + this.state.playingDelay); 699 | 700 | let mainContents; 701 | if (this.state.view === VIEWS.ONBOARDING) { 702 | mainContents = ( 703 | this.setState({ view: VIEWS.PLAYLIST })} /> 707 | ); 708 | 709 | } else if (this.state.view === VIEWS.CONFIG) { 710 | mainContents = ( 711 | this.setState({ view: VIEWS.PLAYER })} 715 | setLocale={this.setLocale} /> 716 | ); 717 | 718 | } else if (this.state.view === VIEWS.PLAYLIST || config.radios.length === 0) { 719 | mainContents = ( 720 | this.setState({ view: VIEWS.CONFIG })} 724 | locale={this.state.locale} /> 725 | ); 726 | 727 | } else if (this.state.view === VIEWS.PLAYER) { 728 | const radioState = {}; 729 | const self = this; 730 | this.state.config.radios.map(function(radioObj, i) { 731 | var radio = radioObj.country + "_" + radioObj.name; 732 | return radioState[radio] = { 733 | metadata: self.state[radio + "|metadata"], 734 | cursor: self.state[radio + "|cursor"], 735 | availableCache: self.state[radio + "|available"], 736 | classList: self.state[radio + "|class"], 737 | } 738 | }); 739 | 740 | mainContents = ( 741 | 751 | ); 752 | } 753 | 754 | return ( 755 | 756 | 757 | 758 | this.state.config.radios.length && this.setState({ view: VIEWS.PLAYER })} 759 | className={classNames({ 'active': this.state.view === VIEWS.PLAYER, 'disabled': !this.state.config.radios.length })}> 760 | {{ en: "Adblock Radio", fr: "Adblock Radio" }[lang]} 761 | 762 | this.setState({ view: VIEWS.PLAYLIST })} 763 | className={classNames({ 'active': this.state.view === VIEWS.PLAYLIST })}> 764 | {{ en: "Playlist", fr: "Playlist" }[lang]} 765 | 766 | this.state.config.radios.length && this.setState({ view: VIEWS.CONFIG })} 767 | className={classNames({ 'active': this.state.view === VIEWS.CONFIG, 'disabled': !this.state.config.radios.length })}> 768 | {{ en: "Filters", fr: "Filtres" }[lang]} 769 | 770 | this.setState({ view: VIEWS.ONBOARDING })} 771 | className={classNames({ 'active': this.state.view === VIEWS.ONBOARDING })}> 772 | {{ en: "Help", fr: "Aide" }[lang]} 773 | 774 | 775 | 776 | 777 | 778 | {mainContents} 779 | 780 | 0} 785 | clickMeta={() => this.setState({ view: VIEWS.PLAYER })} 786 | locale={this.state.locale} 787 | canStart={this.state.playingRadio || this.state.view === VIEWS.PLAYER} /> 788 | 789 | ); 790 | } 791 | 792 | componentDidUpdate() { 793 | var canvasContainerDom = document.getElementById('RadioItem0'); 794 | if (!canvasContainerDom) return; 795 | var cs = getComputedStyle(canvasContainerDom); 796 | var canvasWidth = parseInt(cs.getPropertyValue('width'), 10); 797 | if (canvasWidth === this.state.canvasWidth) return; 798 | this.setState({ canvasWidth: canvasWidth }); 799 | } 800 | } 801 | 802 | const AppParent = styled.div` 803 | width: 100%; 804 | display: flex; 805 | flex-direction: column; 806 | align-items: center; 807 | /*background: url('bg.jpg') no-repeat center center fixed;*/ 808 | background: #dff0ff; 809 | -webkit-background-size: cover; 810 | -moz-background-size: cover; 811 | -o-background-size: cover; 812 | background-size: cover; 813 | height: 100vh; 814 | `; 815 | 816 | const AppView = styled.div` 817 | height: calc(100% - 127px); 818 | max-width: 600px; 819 | margin: 67px auto 0px auto; 820 | position: fixed; 821 | overflow-y: auto; 822 | overflow-x: hidden; 823 | width: 100%; 824 | 825 | &.white { 826 | background: white; 827 | } 828 | `; 829 | 830 | const TabSpacer = styled.div` 831 | height: 55px; 832 | `; 833 | 834 | const Tabs = styled.div` 835 | padding: 10px 0px; 836 | width: 100%; 837 | position: fixed; 838 | height: 45px; 839 | z-index: 1000; 840 | background: white; 841 | border-bottom: 2px solid grey; 842 | `; 843 | 844 | const TabItem = styled.div` 845 | cursor: pointer; 846 | 847 | &.active { 848 | font-weight: bold; 849 | } 850 | 851 | &.disabled { 852 | color: #bbb; 853 | cursor: not-allowed; 854 | } 855 | `; 856 | 857 | /*const Container = styled.div` 858 | z-index: 1000; 859 | background: #eee; 860 | height: 60px; 861 | border-top: 2px solid #888; 862 | bottom: 0; 863 | position: fixed; 864 | width: 100%; 865 | `;*/ 866 | 867 | const MaxWidthContainer = styled.div` 868 | max-width: 600px; 869 | margin: auto; 870 | align-items: center; 871 | display: flex; 872 | justify-content: space-around; 873 | height: 100%; 874 | width: calc(100% - 20px); 875 | background: white; 876 | `; 877 | 878 | export default App; -------------------------------------------------------------------------------- /client/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | }); 9 | -------------------------------------------------------------------------------- /client/src/BlueButton.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const BlueButton = styled.div` 4 | border: 2px solid #699ecd; 5 | color: white; 6 | border-radius: 10px; 7 | margin: 10px; 8 | padding: 10px; 9 | flex-shrink: 0; 10 | background: #699ecd; 11 | box-shadow: 0px 2px 3px grey; 12 | cursor: pointer; 13 | `; 14 | 15 | export default BlueButton; -------------------------------------------------------------------------------- /client/src/Config.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Alexandre Storelli 2 | 3 | import React, { Component } from "react"; 4 | import PropTypes from "prop-types"; 5 | import styled from "styled-components"; 6 | //import classNames from 'classnames'; 7 | import Checkbox from 'rc-checkbox'; 8 | import 'rc-checkbox/assets/index.css'; 9 | import defaultCover from "./img/default_radio_logo.svg"; 10 | import BlueButton from "./BlueButton.js"; 11 | 12 | class Config extends Component { 13 | 14 | constructor() { 15 | super(); 16 | this.toggleContent = this.toggleContent.bind(this); 17 | } 18 | 19 | async toggleContent(country, name, contentType, enabled) { 20 | console.log("toggleContent radio=" + country + "_" + name + " contentType=" + contentType + " enable=" + enabled); 21 | await this.props.toggleContent(country, name, contentType, enabled); 22 | } 23 | 24 | translateContentName(type, lang) { 25 | switch (type) { 26 | case "ads": return { en: "ads", fr: "pubs" }[lang]; 27 | case "speech": return { en: "speech", fr: "prises de parole" }[lang]; 28 | case "music": return { en: "music", fr: "musique" }[lang]; 29 | default: return "unknown content name"; 30 | } 31 | } 32 | 33 | render() { 34 | var lang = this.props.locale; 35 | var self = this; 36 | var current = this.props.config.radios; 37 | 38 | return ( 39 | 40 | {current.map(function(radio, i) { 41 | return ( 42 | 43 | 44 | 45 | 46 | {radio.name} 47 | 48 | 49 | 50 | {["ads", "speech"].map(function(type, j) { 51 | return ( 52 | 53 | self.toggleContent(radio.country, radio.name, type, !e.target.checked)} 56 | disabled={false} 57 | /> 58 |   {{ en: "skip " + self.translateContentName(type, lang), fr: "zapper les " + self.translateContentName(type, lang) }[lang]} 59 | 60 | ) 61 | })} 62 | 63 | 64 | ) 65 | })} 66 | 67 | 68 | {{ en: "When you are ready, you can start listening!", 69 | fr: "Quand vous êtes prêt, vous pouvez écouter la radio !" }[lang]} 70 | 71 | this.props.finish()}> 72 | {{ en: "Listen to the radio", fr: "Écouter la radio" }[lang]} 73 | 74 | 75 | 76 | ) 77 | } 78 | } 79 | 80 | Config.propTypes = { 81 | config: PropTypes.object.isRequired, 82 | locale: PropTypes.string.isRequired, 83 | finish: PropTypes.func.isRequired, 84 | }; 85 | 86 | const PlaylistContainer = styled.div` 87 | flex-grow: 1; 88 | padding-bottom: 10px; 89 | `; 90 | 91 | const PlaylistItem = styled.div` 92 | border: 1px solid grey; 93 | border-radius: 10px; 94 | margin: 10px; 95 | padding: 10px; 96 | flex-shrink: 0; 97 | background: white; 98 | display: flex; 99 | flex-direction: column; 100 | box-shadow: 0px 2px 3px grey; 101 | `; 102 | 103 | const PlaylistItemTopRow = styled.div` 104 | display: flex; 105 | flex-direction: row; 106 | `; 107 | 108 | const PlaylistItemText = styled.p` 109 | flex-grow: 1; 110 | align-self: center; 111 | `; 112 | 113 | const PlaylistItemLogo = styled.img` 114 | width: 50px; 115 | height: 50px; 116 | align-self: center; 117 | margin-right: 10px; 118 | border: 1px solid grey; 119 | `; 120 | 121 | const PlaylistItemConfigContainer = styled.div` 122 | flex-grow: 1; 123 | margin-top: 10px; 124 | `; 125 | 126 | const PlaylistItemConfigItem = styled.label` 127 | margin-right: 10px; 128 | display: block; 129 | margin: 5px 5px 0 5px; 130 | cursor: pointer; 131 | `; 132 | 133 | const ButtonContainer = styled.div` 134 | display: flex; 135 | flex-direction: column; 136 | align-items: center; 137 | background: white; 138 | width: 80%; 139 | margin: auto; 140 | border-radius: 10px; 141 | padding: 10px; 142 | `; 143 | 144 | const ButtonTitle = styled.p` 145 | font-size: bigger; 146 | text-align: center; 147 | margin: 10px 10px 0px 10px; 148 | `; 149 | 150 | export default Config; 151 | -------------------------------------------------------------------------------- /client/src/Controls.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import styled from "styled-components"; 3 | import PropTypes from "prop-types"; 4 | import classNames from 'classnames'; 5 | 6 | import playing from "./img/playing.gif"; 7 | import iconStop from "./img/stop_1279170.svg"; 8 | import iconFlag from "./img/flag2.svg"; 9 | 10 | class Controls extends Component { 11 | constructor(props) { 12 | super(props); 13 | this.state = { 14 | flagAllowed: true, 15 | } 16 | this.flag = this.flag.bind(this); 17 | } 18 | 19 | flag() { 20 | if (!this.state.flagAllowed) return; 21 | this.props.flag(); 22 | const self = this; 23 | self.setState({ flagAllowed: false }); 24 | setTimeout(function() { 25 | self.setState({ flagAllowed: true }); 26 | }, 5000); 27 | } 28 | 29 | render() { 30 | 31 | const lang = this.props.locale; 32 | 33 | var statusText; 34 | if (this.props.playingRadio) { 35 | var delayText = { en: "Live", fr: "En direct" }[lang]; 36 | if (this.props.playingDelay > 0) { 37 | var delaySeconds = Math.round(this.props.playingDelay / 1000); 38 | var delayMinutes = Math.floor(delaySeconds / 60); 39 | delaySeconds = delaySeconds % 60; 40 | var textDelay = (delayMinutes ? delayMinutes + " min" : ""); 41 | textDelay += (delaySeconds ? ((delaySeconds < 10 && delayMinutes ? " 0" : " ") + delaySeconds + "s") : ""); 42 | delayText = { en: textDelay + " ago", fr: "Différé de " + textDelay }[lang]; 43 | } 44 | statusText = ( 45 | 46 | {this.props.playingRadio.split("_")[1]}
47 | {delayText} 48 |
49 | ) 50 | } else if (this.props.canStart) { 51 | statusText = ( 52 | 53 | {{ en: "Start a radio", fr: "Lancez une radio" }[lang]} 54 | 55 | ) 56 | } else { 57 | statusText = ( 58 | 59 | {{ en: "Player is ready", fr: "Le lecteur est prêt" }[lang]} 60 | 61 | ) 62 | } 63 | 64 | var status = ( 65 | this.props.clickableMeta && this.props.clickMeta()}> 68 | {/*{moment(self.state.date).format("HH:mm")} – */} 69 | {statusText} 70 | 71 | ); 72 | 73 | var buttons = ( 74 | 75 | {this.props.playingRadio && 76 | this.flag()} /> 80 | } 81 | {this.props.playingRadio && 82 | this.props.play(null, null, null)} /> 86 | } 87 | 88 | ); 89 | 90 | return ( 91 | 92 | 93 | {this.props.playingRadio && 94 | this.props.clickableMeta && this.props.clickMeta()} /> 96 | } 97 | {status} 98 | {buttons} 99 | {/*metaList={this.state[this.state.playingRadio + "|metadata"]}*/} 100 | 101 | {/**/} 102 | 103 | 104 | 105 | ) 106 | } 107 | } 108 | 109 | Controls.propTypes = { 110 | locale: PropTypes.string.isRequired, 111 | playingRadio: PropTypes.string, 112 | playingDelay: PropTypes.number, 113 | play: PropTypes.func.isRequired, 114 | flag: PropTypes.func.isRequired, 115 | clickableMeta: PropTypes.bool.isRequired, 116 | clickMeta: PropTypes.func.isRequired, 117 | } 118 | 119 | const PlayingGif = styled.img` 120 | align-self: center; 121 | height: 40px; 122 | width: 40px; 123 | margin: 0 -10px 0 10px; 124 | 125 | &.active { 126 | cursor: pointer; 127 | } 128 | `; 129 | 130 | const Container = styled.div` 131 | z-index: 1000; 132 | background: #f8f8f8; 133 | height: 60px; 134 | border-top: 2px solid #888; 135 | bottom: 0; 136 | position: fixed; 137 | width: 100%; 138 | `; 139 | 140 | const MaxWidthContainer = styled.div` 141 | max-width: 600px; 142 | margin: auto; 143 | align-items: center; 144 | display: flex; 145 | justify-content: space-between; 146 | height: 100%; 147 | `; 148 | 149 | const PlaybackButton = styled.img` 150 | height: 35px; 151 | margin-left: 7px; 152 | cursor: pointer; 153 | margin-top: 0px; 154 | 155 | &.flip { 156 | transform: rotate(180deg); 157 | } 158 | &.inactive { 159 | filter: opacity(0.25); 160 | cursor: unset; 161 | } 162 | `; 163 | 164 | const StatusTextContainer = styled.span` 165 | padding: 0px 10px 0 20px; 166 | flex-shrink: 1; 167 | flex-grow: 1; 168 | 169 | &.active { 170 | cursor: pointer; 171 | } 172 | `; 173 | 174 | const DelayText = styled.span` 175 | font-size: 12px; 176 | `; 177 | 178 | const StatusButtonsContainer = styled.span` 179 | padding: 5px 0 0 0; 180 | flex-shrink: 0; 181 | margin-right: 20px; 182 | `; 183 | 184 | export default Controls; -------------------------------------------------------------------------------- /client/src/DelaySVG.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Alexandre Storelli 2 | 3 | import React, { Component } from "react"; 4 | import PropTypes from "prop-types"; 5 | //import styled from "styled-components"; 6 | //import classNames from 'classnames'; 7 | import { colors } from "./colors.js"; 8 | import loading from "./img/loading.svg"; 9 | 10 | const TICKS_INTERVAL = 60000; 11 | 12 | class DelaySVG extends Component { 13 | 14 | constructor(props) { 15 | super(props); 16 | this.play = this.play.bind(this); 17 | this.getCursorPosition = this.getCursorPosition.bind(this); 18 | } 19 | 20 | play(delay) { 21 | console.log("play with delay=" + delay); 22 | this.props.playCallback(delay); 23 | } 24 | 25 | getCursorPosition(event) { 26 | //if (this.props.inactive) return; 27 | var rect = this.refs.canvas.getBoundingClientRect(); 28 | var x = event.clientX - rect.left; 29 | 30 | //var width = this.refs.canvas.getContext("2d").canvas.width; 31 | var newDelay = Math.round((this.props.cacheLen*(1-x/this.props.width)-(this.props.cacheLen-this.props.availableCache))*1000); 32 | //console.log("Canvas click: x=" + x + " width=" + width + " cacheLen=" + this.props.cacheLen + " newDelay=" + newDelay); 33 | this.play(newDelay); 34 | } 35 | 36 | delayToX(width, delay) { 37 | return Math.round(width*(1-(this.props.cacheLen-this.props.availableCache+delay/1000)/this.props.cacheLen)); 38 | } 39 | 40 | render() { 41 | var self = this; 42 | const height = 24; // pixels 43 | const lang = this.props.locale; 44 | 45 | if (!this.props.availableCache) { 46 | return ( 47 | {{ 48 | ) 49 | } 50 | 51 | const cursorX = this.delayToX(this.props.width, this.props.cursor); 52 | 53 | if (this.props.classList) { 54 | var nClasses = this.props.classList.length-1; 55 | var xStartClass = new Array(nClasses); 56 | var xStopClass = new Array(nClasses); 57 | var colorClass = new Array(nClasses); 58 | 59 | for (var i=nClasses; i>=0; i--) { 60 | var cl = this.props.classList[i]; 61 | switch (cl.payload) { 62 | case "0-ads": colorClass[i] = colors.RED; break; 63 | case "1-speech": colorClass[i] = colors.GREEN; break; 64 | case "2-music": colorClass[i] = colors.BLUE; break; 65 | default: colorClass[i] = colors.GREY; 66 | } 67 | xStartClass[i] = Math.max(this.delayToX(this.props.width, +this.props.date-cl.validFrom), 0); 68 | xStopClass[i] = Math.max(xStartClass[i], this.delayToX(this.props.width, cl.validTo ? (+this.props.date-cl.validTo) : 0)); 69 | //ctx.fillRect(xStart, 0.6*height, xStop, height); 70 | } 71 | } 72 | 73 | var nLines = Math.floor(this.props.cacheLen/TICKS_INTERVAL*1000) 74 | var xLines = new Array(nLines); 75 | var offset = this.props.date % TICKS_INTERVAL; 76 | for (let i=0; i<=nLines; i++) { 77 | xLines[i] = this.delayToX(this.props.width, offset + i*TICKS_INTERVAL); 78 | } 79 | const ticksStyle = { 80 | stroke: colors.LIGHT_GREY, 81 | strokeWidth: 1 82 | }; 83 | 84 | const cursorShape = "" + cursorX + "," + 0.6*height + " " + 85 | Math.round(cursorX+0.3*height) + ",0 " + 86 | Math.round(cursorX-0.3*height) + ",0"; 87 | 88 | return ( 89 | 90 | 91 | {/*!isNaN(cursorX) && 92 | 93 | */} 94 | 95 | {this.props.classList && xStartClass.map(function(xStart, i) { 96 | return ( 97 | 98 | ) 99 | })} 100 | {xLines.map(function(x, i) { 101 | if (x < 0) return null 102 | return 103 | })} 104 | 105 | {!isNaN(cursorX) && 106 | 107 | } 108 | 109 | ) 110 | } 111 | } 112 | 113 | DelaySVG.propTypes = { 114 | cursor: PropTypes.number, 115 | availableCache: PropTypes.number, 116 | date: PropTypes.object.isRequired, 117 | cacheLen: PropTypes.number.isRequired, 118 | playCallback: PropTypes.func.isRequired, 119 | classList: PropTypes.array, 120 | locale: PropTypes.string.isRequired, 121 | }; 122 | 123 | /*var canvasContainerStyle = { 124 | width: "100%", 125 | height: "10px" 126 | }*/ 127 | 128 | export default DelaySVG; 129 | -------------------------------------------------------------------------------- /client/src/Flag.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import styled from "styled-components"; 4 | import classNames from "classnames"; 5 | 6 | import Argentina from "./img/flags/Argentina.svg"; 7 | import Belgium from "./img/flags/Belgium.svg"; 8 | import Canada from "./img/flags/Canada.svg"; 9 | import Finland from "./img/flags/Finland.svg"; 10 | import France from "./img/flags/France.svg"; 11 | import Germany from "./img/flags/Germany.svg"; 12 | import Italy from "./img/flags/Italy.svg"; 13 | import Netherlands from "./img/flags/Netherlands.svg"; 14 | import NewZealand from "./img/flags/New Zealand.svg"; 15 | import Slovakia from "./img/flags/Slovakia.svg"; 16 | import Spain from "./img/flags/Spain.svg"; 17 | import Switzerland from "./img/flags/Switzerland.svg"; 18 | import UK from "./img/flags/United Kingdom.svg"; 19 | import USA from "./img/flags/United States of America.svg"; 20 | import Uruguay from "./img/flags/Uruguay.svg"; 21 | 22 | const flags = { 23 | "Argentina": Argentina, 24 | "Belgium": Belgium, 25 | "Canada": Canada, 26 | "Finland": Finland, 27 | "France": France, 28 | "Italy": Italy, 29 | "Germany": Germany, 30 | "Netherlands": Netherlands, 31 | "New Zealand": NewZealand, 32 | "Slovakia": Slovakia, 33 | "Spain": Spain, 34 | "Switzerland": Switzerland, 35 | "United Kingdom": UK, 36 | "United States of America": USA, 37 | "Uruguay": Uruguay, 38 | } 39 | 40 | const FlagBox = styled.div` 41 | border-radius: 5px; 42 | display: inline-block; 43 | margin: 5px; 44 | &.selected { 45 | margin: 2px; 46 | border: #ef66b0 3px solid; 47 | } 48 | &.clickable { 49 | cursor: pointer; 50 | } 51 | `; 52 | 53 | const Flag = styled.img` 54 | /*width: 64px; 55 | height: 48px;*/ 56 | margin: 4px 4px 1px 4px; 57 | border-radius: 5px; 58 | border: 0px; 59 | `; 60 | 61 | class FlagContainer extends Component { 62 | constructor() { 63 | super(); 64 | this.countries = Object.keys(flags); 65 | } 66 | 67 | render() { 68 | return ( 69 | 70 | 71 | 72 | ); 73 | } 74 | } 75 | 76 | FlagContainer.propTypes = { 77 | country: PropTypes.string.isRequired, 78 | selected: PropTypes.bool.isRequired, 79 | onClick: PropTypes.func, 80 | height: PropTypes.number, 81 | width: PropTypes.number 82 | }; 83 | 84 | export default FlagContainer; 85 | -------------------------------------------------------------------------------- /client/src/Onboarding.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | import styled from "styled-components"; 4 | import FlagContainer from "./Flag.js"; 5 | import { colorByType } from "./colors.js"; 6 | import BlueButton from "./BlueButton.js"; 7 | import DelaySVG from "./DelaySVG.js"; 8 | 9 | import abrlogo from './img/round_logo.png'; 10 | import leap from './img/leap_219178_ad.svg'; 11 | import meter from './img/meter_1697884.svg'; 12 | import target from './img/a_plus.jpg'; 13 | import flag from './img/flag2.svg'; 14 | 15 | const LOCALE_FOR_COUNTRY = { 16 | "United Kingdom": "en", 17 | "France": "fr" 18 | } 19 | 20 | class Onboarding extends Component { 21 | constructor() { 22 | super(); 23 | this.state = { 24 | step: 0, 25 | } 26 | } 27 | 28 | translateContentName(type, lang) { 29 | switch (type) { 30 | case "ads": return { en: "ads", fr: "pubs" }[lang]; 31 | case "speech": return { en: "speech", fr: "prises de parole" }[lang]; 32 | case "music": return { en: "music", fr: "musique" }[lang]; 33 | default: return "unknown content name"; 34 | } 35 | } 36 | 37 | render() { 38 | const self = this; 39 | const lang = this.props.locale; 40 | let contents; 41 | 42 | switch (this.state.step) { 43 | case 0: 44 | contents = ( 45 | 46 | 47 |

Bienvenue sur Adblock Radio Buffer !

48 |

{{ en: "Listen to the radio and fast-forward ads.", fr: "Écoutez la radio et sautez les pubs." }[lang]}

49 | 50 | {Object.keys(LOCALE_FOR_COUNTRY).map(function(lang, index) { 51 | return ( 52 | self.props.setLocale(LOCALE_FOR_COUNTRY[lang])} 56 | width={32} 57 | height={24} > 58 | 59 | ); 60 | })} 61 | 62 | 63 |

64 | {{ en: "Adblock Radio SAS. All rights reserved, 2018", 65 | fr: "Adblock Radio SAS. Tous droits réservés, 2018." }[lang]} 66 |

67 |

68 | {{ en: "More info on ", fr: "Plus d'informations sur " }[lang]} adblockradio.com. 69 |

70 |
71 | 72 | this.setState({ step: this.state.step + 1})}> 73 | {{ en: "Next", fr: "Suivant" }[lang]} 74 | 75 |
76 | ); 77 | break; 78 | case 1: 79 | contents = ( 80 | 81 | 82 | 83 |

{{ en: "You are about to listen to your favorite radios without ads and without ad breaks.", 84 | fr: "Vous allez écouter vos radios favorites sans pubs et sans interruptions." }[lang]}

85 | 86 |

{{ en: "How is it possible? You will listen with a delay of a few minutes.", 87 | fr: "Comment est-ce possible ? Vous allez écouter avec un délai de quelques minutes." }[lang]}

88 | 89 |

{{ en: "That way, you fast-forward ads and chit-chat and get continuous interesting content!", 90 | fr: "Ainsi, vous passez les pubs et le blabla en avance rapide et obtenez le contenu intéressant en continu !"}[lang]}

91 | 92 |

{{ en: "If there have been too many ads so that they can no longer be skipped, the player switches to another station.", 93 | fr: "S'il y a eu trop de publicités et que vous avez rattrapé le direct, le lecteur passe sur une autre station."}[lang]}

94 | 95 | this.setState({ step: this.state.step + 1})}> 96 | {{ en: "Next", fr: "Suivant" }[lang]} 97 | 98 |
99 | ); 100 | break; 101 | case 2: 102 | contents = ( 103 | 104 | 105 | 106 |

{{ en: "The player gives an overview on what kind of content has just been broadcast.", 107 | fr: "Le lecteur donne un aperçu de la nature du contenu qui passe à la radio."}[lang]}

108 |

{{ en: "On the left, the delayed playback. On the right, live audio. The pink cursor tells you what you listen to.", 109 | fr: "À gauche, le différé. À droite, le direct. Le curseur rose, là où vous écoutez."}[lang]}

110 | 111 | 142 |

{{ en: "Here is the color it uses.", 143 | fr: "Voici les couleurs qu'il utilise."}[lang]}

144 |
    145 | {["music", "speech", "ads"].map(function(type, index) { 146 | return ( 147 | 148 | 149 | {self.translateContentName(type, lang)} 150 | 151 | ); 152 | })} 153 |
154 | this.setState({ step: this.state.step + 1})}> 155 | {{ en: "Next", fr: "Suivant" }[lang]} 156 | 157 | 158 |
159 | ); 160 | break; 161 | 162 | case 3: 163 | contents = ( 164 | 165 | 166 | 167 |

{{ en: "Sometimes Adblock Radio is wrong…", 168 | fr: "Parfois Adblock Radio fait des erreurs…"}[lang]}

169 |

{{ en: "Not a problem! You can tell him and he will improve.", 170 | fr: "Pas grave ! Vous pouvez lui dire et il s'améliorera."}[lang]}

171 |

{{ en: "When a radio is playing, here is the button to do a report:", 172 | fr: "Voici le bouton de signalement qui apparaît pendant l'écoute :"}[lang]}

173 | 174 | 175 | 176 | this.props.finished()}> 177 | {{ en: "Choose my radios", fr: "Choisir mes radios" }[lang]} 178 | 179 |
180 | ); 181 | break; 182 | 183 | default: 184 | contents = null 185 | } 186 | 187 | return ( 188 | 189 | {contents} 190 | {/*
    191 |
  • 192 | 1 193 |
  • 194 |
  • 195 | 2 196 |
  • 197 |
  • 198 | 3 199 |
  • 200 |
*/} 201 |
202 | ); 203 | } 204 | } 205 | 206 | Onboarding.propTypes = { 207 | locale: PropTypes.string.isRequired, 208 | setLocale: PropTypes.func.isRequired, 209 | finished: PropTypes.func.isRequired, 210 | canvasWidth: PropTypes.number, 211 | } 212 | 213 | const AbrLogo = styled.img` 214 | width: 96px; 215 | border-radius: 100%; 216 | margin-bottom: 20px; 217 | `; 218 | 219 | const Container = styled.div` 220 | width: 100%; 221 | display: flex; 222 | flex-direction: column; 223 | background: white; 224 | height: 100%; 225 | `; 226 | 227 | const ContentsContainer = styled.div` 228 | text-align: center; 229 | padding: 15px 30px 15px 30px; 230 | flex-grow: 1; 231 | display: flex; 232 | flex-direction: column; 233 | justify-content: space-between; 234 | align-items: center; 235 | background: white; 236 | height: 100%; 237 | `; 238 | 239 | const ChoiceL10nContainer = styled.div` 240 | align-self: center; 241 | flex-grow: 1; 242 | `; 243 | 244 | const ColorItem = styled.li` 245 | display: flex; 246 | margin-bottom: 3px; 247 | `; 248 | 249 | const ColorDot = styled.span` 250 | width: 18px; 251 | height: 18px; 252 | border-radius: 9px; 253 | `; 254 | 255 | const ColorLabel = styled.span` 256 | align-self: center; 257 | margin-left: 10px; 258 | `; 259 | 260 | const Credits = styled.div` 261 | font-size: small; 262 | `; 263 | 264 | 265 | export default Onboarding; -------------------------------------------------------------------------------- /client/src/Playing.js: -------------------------------------------------------------------------------- 1 | 2 | import React, { Component } from 'react'; 3 | import DelaySVG from './DelaySVG.js'; 4 | import styled from "styled-components"; 5 | import classNames from 'classnames'; 6 | import PropTypes from "prop-types"; 7 | import SoloMessage from './SoloMessage'; 8 | 9 | 10 | class Playlist extends Component { 11 | /*constructor(props) { 12 | super(props); 13 | }*/ 14 | 15 | render() { 16 | const self = this; 17 | const lang = this.props.locale; 18 | 19 | let minimumCache = null; 20 | return ( 21 | 22 | {this.props.communicationError && 23 | 24 |

{{ en: "The communication with the server is temporarily unavailable…", fr: "La connection au serveur est momentanément interrompue…" }[lang]}

25 |
26 | } 27 | {this.props.config.radios.map(function(radioObj, i) { 28 | var radio = radioObj.country + "_" + radioObj.name; 29 | var playing = self.props.playingRadio === radio; 30 | var meta = self.props.getCurrentMetaForRadio(radio); 31 | minimumCache = Math.max(minimumCache, self.props.radioState[radio].availableCache); 32 | 33 | return ( 34 | 37 | 38 | 39 | 40 | 41 | 42 | {meta.text || radio} 43 | 44 | {meta.image && 45 | 46 | } 47 | 48 | 49 | 50 | 59 | 60 | 61 | ) 62 | })} 63 | 64 | {minimumCache < this.props.config.user.cacheLen / 2 && 65 | 66 |

67 | {{ en: "The player is building the buffer.", 68 | fr: "Le lecteur enregistre les radios."}[lang]} 69 |

70 |

71 | {{ en: "You can listen but ads fast-forwards may not yet be available.", 72 | fr: "Vous pouvez commencer l'écoute mais le saut des publicités ne sera peut-être pas encore possible."}[lang]} 73 |

74 |

75 | {{ en: "This message will disappear in a few minutes.", 76 | fr: "Ce message disparaîtra au bout de quelques minutes."}[lang]} 77 |

78 |
79 | } 80 |
81 | ) 82 | } 83 | } 84 | 85 | Playlist.propTypes = { 86 | communicationError: PropTypes.bool.isRequired, 87 | locale: PropTypes.string.isRequired, 88 | config: PropTypes.object.isRequired, 89 | playingRadio: PropTypes.string, 90 | radioState: PropTypes.object, 91 | canvasWidth: PropTypes.number, 92 | date: PropTypes.number.isRequired, 93 | clockDiff: PropTypes.number.isRequired, 94 | play: PropTypes.func.isRequired, 95 | getCurrentMetaForRadio: PropTypes.func.isRequired, 96 | } 97 | 98 | const RadioList = styled.div` 99 | display: flex; 100 | justify-content: center; 101 | flex-grow: 1; 102 | flex-direction: column; 103 | align-self: auto; 104 | padding-bottom: 10px; 105 | overflow-y: auto; 106 | `; 107 | 108 | const RadioItem = styled.div` 109 | border: 1px solid grey; 110 | border-radius: 10px; 111 | margin: 10px 10px 0px 10px; 112 | padding: 10px 10px 6px 10px; 113 | width: calc(100% - 44px); 114 | cursor: pointer; 115 | background: white; 116 | box-shadow: 0px 2px 3px grey; 117 | 118 | &.playing { 119 | border: 2px solid #ef66b0; 120 | box-shadow: 0px 2px 3px #ef66b0; 121 | } 122 | `; 123 | 124 | const RadioItemTopLine = styled.div` 125 | display: flex; 126 | flex-direction: row; 127 | `; 128 | 129 | const RadioLogo = styled.img` 130 | height: 60px; 131 | width: 60px; 132 | border: 1px solid grey; 133 | `; 134 | 135 | const MetadataItem = styled.div` 136 | flex-grow: 1; 137 | border-radius: 0 5px 5px 0; 138 | padding: 0 10px; 139 | flex-shrink: 1; 140 | background: #f8f8f8; 141 | display: flex; 142 | cursor: pointer; 143 | `; 144 | 145 | const MetadataText = styled.p` 146 | flex-grow: 1; 147 | align-self: center; 148 | margin: 10px 0; 149 | font-size: 13px; 150 | `; 151 | 152 | const MetadataCover = styled.img` 153 | width: 40px; 154 | height: 40px; 155 | align-self: center; 156 | margin-left: 10px; 157 | `; 158 | 159 | const CacheWarning = styled.div` 160 | border: 3px solid orange; 161 | width: 80%; 162 | margin: 20px auto 0px auto; 163 | padding: 10px 10px 0px 10px; 164 | border-radius: 5px; 165 | background: white; 166 | `; 167 | 168 | export default Playlist; -------------------------------------------------------------------------------- /client/src/Playlist.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Alexandre Storelli 2 | 3 | import React, { Component } from "react"; 4 | import PropTypes from "prop-types"; 5 | import styled from "styled-components"; 6 | //import classNames from 'classnames'; 7 | import 'rc-checkbox/assets/index.css'; 8 | import defaultCover from "./img/default_radio_logo.svg"; 9 | import removeIcon from "./img/remove_991614.svg"; 10 | import FlagContainer from "./Flag.js"; 11 | import BlueButton from "./BlueButton.js"; 12 | 13 | 14 | const { countries } = (new FlagContainer()); 15 | 16 | class Playlist extends Component { 17 | constructor(props) { 18 | super(props); 19 | this.insert = this.insert.bind(this); 20 | this.remove = this.remove.bind(this); 21 | this.componentDidMount = this.componentDidMount.bind(this); 22 | this.state = { 23 | radiosLoaded: false, 24 | radiosError: false, 25 | radios: [], 26 | isElectron: navigator.userAgent.toLowerCase().indexOf(' electron/') > -1, 27 | selectionCountry: null, 28 | } 29 | } 30 | 31 | translateContentName(type, lang) { 32 | switch (type) { 33 | case "ads": return { en: "ads", fr: "pubs" }[lang]; 34 | case "speech": return { en: "speech", fr: "prises de parole" }[lang]; 35 | case "music": return { en: "music", fr: "musique" }[lang]; 36 | default: return "unknown content name"; 37 | } 38 | } 39 | 40 | async componentDidMount() { 41 | try { 42 | if (!this.state.isElectron) { 43 | const request = await fetch("config/radios/available?t=" + Math.round(Math.random()*1000000)); 44 | const res = await request.text(); 45 | var radios = JSON.parse(res); 46 | } else { 47 | radios = navigator.abrserver.getAvailableInactive(); 48 | } 49 | this.setState({ radiosLoaded: true, radios: radios, radiosError: false }); 50 | } catch (e) { 51 | console.log("problem getting available radios. err=" + e.message); 52 | this.setState({ radiosLoaded: true, radiosError: true }); 53 | } 54 | } 55 | 56 | async insert(country, name) { 57 | await this.props.insertRadio(country, name); 58 | this.componentDidMount(); 59 | } 60 | 61 | async remove(country, name) { 62 | await this.props.removeRadio(country, name); 63 | this.componentDidMount(); 64 | } 65 | 66 | /*toggleContent(country, name, contentType, enabled) { 67 | console.log("toggleContent radio=" + country + "_" + name + " contentType=" + contentType + " enable=" + enabled); 68 | this.props.toggleContent(country, name, contentType, enabled, this.componentDidMount); 69 | }*/ 70 | 71 | render() { 72 | var lang = this.props.locale; 73 | var self = this; 74 | if (this.state.radiosError) { 75 | return ( 76 | 77 |

{{en: "Oops, could not get the list of radios.", fr: "Oops, problème pour récupérer la liste des radios." }[lang]}

78 |

{{en: "Check that the server is running and reload this page", fr: "Vérifiez que le serveur est actif et rechargez cette page" }[lang]}

79 |
80 | ); 81 | } else if (!this.state.radiosLoaded) { 82 | return ( 83 | 84 |

{{en: "Loading…", fr: "Chargement…" }[lang]}

85 |
86 | ); 87 | } 88 | 89 | var current = this.props.config.radios; 90 | var available = this.state.radios; 91 | var playlistFull = this.props.config.radios.length >= this.props.config.user.maxRadios; 92 | 93 | return ( 94 | 95 | {/*!playlistEmpty && 96 | {{ en: "Your playlist", fr: "Votre playlist" }[lang]} 97 | */} 98 | {current.map(function(radio, i) { 99 | // TODO it would be nice to make the default cover display if the original logo gives a 40x error. 100 | // as is, it does not work 101 | return ( 102 | 103 | 104 | {e.target.src=defaultCover}} /> 106 | 107 | {radio.name} 108 | 109 | self.remove(radio.country, radio.name)} /> 110 | 111 | 112 | ) 113 | })} 114 | {current.length > 1 && 115 | 116 | 117 | {{en: "When your playlist is ready, customize your filters.", 118 | fr: "Quand votre playlist est prête, personnalisez vos filtres."}[lang]} 119 | 120 | this.props.finish()}> 121 | {{ en: "Select filters", fr: "Choisir les filtres" }[lang]} 122 | 123 | 124 | } 125 | {!playlistFull ? 126 | 127 | 128 | {{ en: "Add up to MAX radios to your playlist", 129 | fr: "Ajoutez jusqu'à MAX radios à votre playlist" }[lang].replace("MAX", this.props.config.user.maxRadios)} 130 | 131 | 132 | {/*

{{ en: "Choose the country of radios", fr: "Choisissez le pays des radios" }[lang]}

*/} 133 | {countries.map(function(lang, index) { 134 | return ( 135 | self.setState({ selectionCountry: index })} 139 | width={32} 140 | height={24} > 141 | 142 | ); 143 | })} 144 |
145 | 146 | {available 147 | .filter(function(r) { 148 | if (self.state.selectionCountry === null) return true; 149 | return r.country === countries[self.state.selectionCountry]; 150 | }) 151 | .sort(function(r1, r2) { 152 | return r1.name > r2.name; 153 | }) 154 | .map(function(radio, i) { 155 | return ( 156 | 157 | 158 | 159 | 160 | {radio.name} 161 | 162 | self.insert(radio.country, radio.name) }/> 163 | 164 | 165 | ) 166 | }) 167 | } 168 |
169 | : 170 | 171 | 172 | {{ en: "If you want to change the playlist, first make room in it.", 173 | fr: "Si vous souhaitez modifier votre playlist, faites-y d'abord de la place." }[lang]} 174 | 175 | 176 | 177 | } 178 | 179 | 180 |
181 | ) 182 | } 183 | } 184 | 185 | Playlist.propTypes = { 186 | config: PropTypes.object.isRequired, 187 | insertRadio: PropTypes.func.isRequired, 188 | removeRadio: PropTypes.func.isRequired, 189 | locale: PropTypes.string.isRequired, 190 | finish: PropTypes.func.isRequired, 191 | }; 192 | 193 | const PlaylistContainer = styled.div` 194 | flex-grow: 1; 195 | padding-bottom: 10px; 196 | `; 197 | 198 | const PlaylistSectionTitle = styled.p` 199 | font-size: bigger; 200 | text-align: center; 201 | margin: 10px 10px 0px 10px; 202 | `; 203 | 204 | const PlaylistItem = styled.div` 205 | border: 1px solid grey; 206 | border-radius: 10px; 207 | margin: 10px; 208 | padding: 10px; 209 | flex-shrink: 0; 210 | background: white; 211 | display: flex; 212 | flex-direction: column; 213 | box-shadow: 0px 2px 3px grey; 214 | `; 215 | 216 | const PlaylistItemTopRow = styled.div` 217 | display: flex; 218 | flex-direction: row; 219 | `; 220 | 221 | const PlaylistItemText = styled.p` 222 | flex-grow: 1; 223 | align-self: center; 224 | `; 225 | 226 | const PlaylistItemLogo = styled.img` 227 | width: 50px; 228 | height: 50px; 229 | align-self: center; 230 | margin-right: 10px; 231 | border: 1px solid grey; 232 | `; 233 | 234 | const RemoveIcon = styled.img` 235 | width: 32px; 236 | height: 32px; 237 | align-self: center; 238 | cursor: pointer; 239 | `; 240 | 241 | const AddIcon = styled.img` 242 | width: 32px; 243 | height: 32px; 244 | align-self: center; 245 | transform: rotate(45deg); 246 | cursor: pointer; 247 | `; 248 | 249 | const SoloMessage = styled.div` 250 | align-self: center; 251 | margin: 50px auto; 252 | padding: 20px 40px; 253 | background: white; 254 | border: 1px solid grey; 255 | border-radius: 20px; 256 | `; 257 | 258 | const ChoiceCountryContainer = styled.div` 259 | display: flex; 260 | flex-direction: row; 261 | justify-content: center; 262 | flex-wrap: wrap; 263 | `; 264 | 265 | const AddRadiosContainer = styled.div` 266 | margin-top: 30px; 267 | `; 268 | 269 | const FullNoticeContainer = styled.div` 270 | display: flex; 271 | flex-direction: column; 272 | align-items: center; 273 | background: white; 274 | width: 80%; 275 | margin: auto; 276 | border-radius: 10px; 277 | padding: 10px; 278 | `; 279 | 280 | export default Playlist; 281 | 282 | /*&.playing { 283 | width: 60px; 284 | height: 60px; 285 | }*/ 286 | -------------------------------------------------------------------------------- /client/src/SoloMessage.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const SoloMessage = styled.div` 4 | align-self: center; 5 | margin: 50px auto; 6 | padding: 20px 40px; 7 | background: white; 8 | border: 1px solid grey; 9 | border-radius: 20px; 10 | `; 11 | 12 | export default SoloMessage; -------------------------------------------------------------------------------- /client/src/audio.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2018 Alexandre Storelli 2 | /* global Media */ 3 | /* global Android */ 4 | 5 | const isElectron = navigator.userAgent.toLowerCase().indexOf(' electron/') > -1; 6 | const isCordovaApp = document.URL.indexOf('http://') === -1 && document.URL.indexOf('https://') === -1 && !isElectron; 7 | 8 | var audioElement, play, stop, setVolume; 9 | 10 | if (isElectron) { 11 | console.log("listen: detected Electron env"); 12 | 13 | let audioCtx, gainNode; 14 | let source; 15 | let startTime; //, startPlayback; 16 | 17 | function newContext() { 18 | console.log("new context"); 19 | audioCtx = new (window.AudioContext || window.webkitAudioContext)(); 20 | gainNode = audioCtx.createGain(); 21 | gainNode.gain.value = 0.5; 22 | gainNode.connect(audioCtx.destination); 23 | } 24 | 25 | newContext(); 26 | 27 | play = function(url, callback) { 28 | if (startTime) { 29 | stop(); 30 | return setTimeout(function() { 31 | play(url, callback); 32 | }, 50); 33 | } 34 | startTime = +new Date(); 35 | let nextStartTime = null; //audioCtx.currentTime; 36 | //audioElement = document.createElement('audio'); 37 | const spl = decodeURIComponent(url).split('?')[0].split("/"); // assuming following URL format: "listen/" + encodeURIComponent(radio) + "/" + (delay/1000) 38 | navigator.abrlisten.play(spl[1], spl[2], startTime, function(receivedStartTime, PCMAudioChunk) { 39 | if (receivedStartTime !== startTime) { 40 | console.log('received obsolete PCM chunk'); 41 | return; 42 | } 43 | if (!nextStartTime) { 44 | console.log('start playback'); 45 | nextStartTime = audioCtx.currentTime; 46 | } 47 | //if (!startPlayback) startPlayback = new Date(); 48 | const PCMAudioChunk2 = Int8Array.from(PCMAudioChunk); //); 49 | const frames = PCMAudioChunk2.byteLength / 4; 50 | //console.log(frames / 44100 + " s => cursor = " + nextStartTime + " buffer=" + (nextStartTime - ((+new Date() - startPlayback)/1000) + " s")); 51 | const arrayBuffer = audioCtx.createBuffer(2, frames, 44100); 52 | 53 | const nowBufferingL = arrayBuffer.getChannelData(0); 54 | const nowBufferingR = arrayBuffer.getChannelData(1); 55 | for (var i = 0; i < frames; i++) { 56 | nowBufferingL[i] = (PCMAudioChunk2[4*i] + 256 * PCMAudioChunk2[4*i + 1]) / 32768; 57 | nowBufferingR[i] = (PCMAudioChunk2[4*i + 2] + 256 * PCMAudioChunk2[4*i + 3]) / 32768; 58 | } 59 | 60 | source = audioCtx.createBufferSource(); 61 | source.buffer = arrayBuffer; 62 | source.connect(gainNode); 63 | source.start(nextStartTime); 64 | nextStartTime += frames / 44100; 65 | }); 66 | } 67 | 68 | stop = function() { 69 | console.log("playback stop"); 70 | navigator.abrlisten.stop(); 71 | //source.stop(audioCtx.currentTime);//disconnect(gainNode); 72 | audioCtx.close(); 73 | startTime = null; 74 | newContext(); 75 | //setVolume(0); 76 | } 77 | 78 | setVolume = function(vol) { 79 | console.log("set volume = " + vol); 80 | gainNode.gain.value = vol; 81 | } 82 | 83 | } else if (isCordovaApp) { 84 | play = function(url, callback) { 85 | if (audioElement && audioElement.stop) audioElement.stop(); 86 | audioElement = new Media(url, function() { 87 | console.log("stream successfully loaded"); 88 | }, function(err) { 89 | console.log("stream loading error=" + err); 90 | }, function(status) { 91 | console.log("stream status=" + status); 92 | }); 93 | audioElement.play(); 94 | if (callback) setImmediate(callback); 95 | } 96 | 97 | stop = function() { 98 | audioElement.stop(); 99 | } 100 | 101 | setVolume = function(vol) { 102 | audioElement.setVolume(vol); 103 | } 104 | 105 | } else if (navigator.userAgent === "abr_android") { 106 | 107 | // bindings to native Android APIs 108 | play = function(url, callback) { 109 | console.log("android: play url=" + url); 110 | Android.playbackStart(url, callback); 111 | } 112 | stop = function() { 113 | //console.log("android: stop"); 114 | Android.playbackStop(); 115 | } 116 | setVolume = function(vol) { 117 | //console.log("android: set vol=" + Math.round(vol*100) + "%"); 118 | Android.playbackSetVolume("" + Math.round(vol*100)); 119 | } 120 | 121 | } else { 122 | audioElement = document.createElement('audio'); 123 | 124 | audioElement.addEventListener("error", function(err) { 125 | console.log("playing error: ") 126 | console.log(err); 127 | }); 128 | 129 | audioElement.addEventListener("play", function() { 130 | console.log("playback started"); 131 | }); 132 | 133 | play = function(url, callback) { // https://developers.google.com/web/updates/2017/06/play-request-was-interrupted 134 | console.log("play " + url); 135 | audioElement.src = url; 136 | var playPromise = audioElement.play(); 137 | if (playPromise !== undefined) { 138 | playPromise.then(_ => { 139 | if (callback) callback(null); 140 | }) 141 | .catch(error => { 142 | if (callback) callback(error); 143 | }); 144 | } 145 | } 146 | 147 | stop = function() { 148 | audioElement.src = ""; 149 | audioElement.load(); 150 | } 151 | 152 | setVolume = function(volume) { 153 | audioElement.volume = volume; 154 | } 155 | 156 | } 157 | 158 | exports.play = play; 159 | exports.stop = stop; 160 | exports.setVolume = setVolume; 161 | -------------------------------------------------------------------------------- /client/src/colors.js: -------------------------------------------------------------------------------- 1 | exports.colors = { 2 | GREY: "rgba(192,192,192,1)", 3 | LIGHT_GREY: "rgba(225,225,225,1)", 4 | BLUE: "rgb(0, 181, 222)", /* music */ 5 | RED: "rgb(255, 104, 104)", /* ads */ 6 | GREEN: "rgb(138, 209, 21)", /* speech */ 7 | YELLOW: "rgba(128,128,0,1)", 8 | PINK: "rgb(239, 102, 176)", /*#ef66b0*/ 9 | LIGHT_PINK: "rgba(239, 102, 176, 0.5)" 10 | } 11 | 12 | exports.colorByType = function(type) { 13 | switch (type) { 14 | case "ads": return exports.colors.RED; 15 | case "speech": return exports.colors.GREEN; 16 | case "music": return exports.colors.BLUE; 17 | default: return exports.colors.GREY; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /client/src/img/135894-ads.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/img/CREDITS.md: -------------------------------------------------------------------------------- 1 | leap_219178_ad.svg 2 | Derived work from: 3 | https://thenounproject.com/search/?q=219178&i=219178 4 | Gilbert Bages, ES 5 | https://thenounproject.com/search/?q=135894&i135894 6 | Jim Slatton, US 7 | 8 | meter_1697884.svg 9 | https://thenounproject.com/search/?q=1697884&i=1697884 10 | Dinosoft Labs, PK 11 | 12 | remove_991614.svg 13 | https://thenounproject.com/search/?q=991614&i=991614 14 | Setyo Ari Wibowo, ID 15 | 16 | start_1279169.svg 17 | https://thenounproject.com/search/?q=1279169&i=1279169 18 | krishna 19 | 20 | 21 | -------------------------------------------------------------------------------- /client/src/img/a_plus.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adblockradio/buffer/c7e20e93a9476af1d407099c0dc63cca1ef8e11a/client/src/img/a_plus.jpg -------------------------------------------------------------------------------- /client/src/img/default_radio_logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 22 | 24 | 42 | 44 | 45 | 47 | image/svg+xml 48 | 50 | 51 | 52 | 53 | 54 | 59 | 66 | 72 | 76 | 81 | 86 | 91 | 96 | 102 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /client/src/img/flag2.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml -------------------------------------------------------------------------------- /client/src/img/flags/Argentina.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /client/src/img/flags/Belgium.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /client/src/img/flags/Canada.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/img/flags/Finland.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /client/src/img/flags/France.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/src/img/flags/Germany.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | Flag of Germany 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /client/src/img/flags/Italy.svg: -------------------------------------------------------------------------------- 1 | 2 | 19 | 21 | 22 | 24 | image/svg+xml 25 | 27 | 28 | 29 | 30 | 32 | 47 | 52 | 59 | 65 | 66 | -------------------------------------------------------------------------------- /client/src/img/flags/Netherlands.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/src/img/flags/New Zealand.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /client/src/img/flags/Slovakia.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Flag of Slovakia 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /client/src/img/flags/Spain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adblockradio/buffer/c7e20e93a9476af1d407099c0dc63cca1ef8e11a/client/src/img/flags/Spain.png -------------------------------------------------------------------------------- /client/src/img/flags/Spain.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /client/src/img/flags/Switzerland.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/img/flags/United Kingdom.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /client/src/img/flags/United States of America.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /client/src/img/flags/Uruguay.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | Flag of Uruguay 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /client/src/img/leap_219178.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/img/leap_219178_ad.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml -------------------------------------------------------------------------------- /client/src/img/loading.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /client/src/img/meter_1697884.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/src/img/playing.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adblockradio/buffer/c7e20e93a9476af1d407099c0dc63cca1ef8e11a/client/src/img/playing.gif -------------------------------------------------------------------------------- /client/src/img/remove_991614.svg: -------------------------------------------------------------------------------- 1 | 2 | image/svg+xml -------------------------------------------------------------------------------- /client/src/img/round_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adblockradio/buffer/c7e20e93a9476af1d407099c0dc63cca1ef8e11a/client/src/img/round_logo.png -------------------------------------------------------------------------------- /client/src/img/start_1279169.svg: -------------------------------------------------------------------------------- 1 | 10 -------------------------------------------------------------------------------- /client/src/img/stop_1279170.svg: -------------------------------------------------------------------------------- 1 | 2 | 19 | 21 | 22 | 24 | image/svg+xml 25 | 27 | 28 | 29 | 30 | 32 | 52 | 10 54 | 63 | 68 | 69 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | 6 | ReactDOM.render(, document.getElementById('root')); -------------------------------------------------------------------------------- /config/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adblockradio/buffer/c7e20e93a9476af1d407099c0dc63cca1ef8e11a/config/.gitkeep -------------------------------------------------------------------------------- /deploy.sh: -------------------------------------------------------------------------------- 1 | scp ../webradio-metadata/*.json dome:/home/alexandre/webradio-metadata/. 2 | scp ../webradio-metadata/*.js dome:/home/alexandre/webradio-metadata/. 3 | scp ../webradio-metadata/parsers/*.js dome:/home/alexandre/webradio-metadata/parsers/. 4 | scp ../adblockradio-dl/*.json dome:/home/alexandre/adblockradio-dl/. 5 | scp ../adblockradio-dl/*.js dome:/home/alexandre/adblockradio-dl/. 6 | scp *.js dome:/home/alexandre/buffer-server/. 7 | scp *.json dome:/home/alexandre/buffer-server/. 8 | scp ../log/log.js dome:/home/alexandre/buffer-server/. 9 | scp ../log/log.js dome:/home/alexandre/adblockradio-dl/log.js 10 | cd client/ 11 | npm run build 12 | scp -r build dome:/home/alexandre/buffer-server/client/. 13 | -------------------------------------------------------------------------------- /doc/abr-buffer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adblockradio/buffer/c7e20e93a9476af1d407099c0dc63cca1ef8e11a/doc/abr-buffer.png -------------------------------------------------------------------------------- /docker-build.sh: -------------------------------------------------------------------------------- 1 | VERSION=$1 2 | 3 | cd client 4 | npm install 5 | npm run build 6 | cd .. 7 | 8 | docker build -t adblockradio/buffer:$VERSION . 9 | -------------------------------------------------------------------------------- /docker-run.sh: -------------------------------------------------------------------------------- 1 | VERSION=$1 2 | 3 | docker run -it -p 9820:9820 -a STDOUT \ 4 | --mount type=bind,source="$(pwd)"/config,target=/usr/src/adblockradio-buffer/config \ 5 | --mount type=bind,source="$(pwd)"/log,target=/usr/src/adblockradio-buffer/log \ 6 | adblockradio/buffer:$VERSION 7 | -------------------------------------------------------------------------------- /electron.js: -------------------------------------------------------------------------------- 1 | module.exports = function(Config) { 2 | // open electron window 3 | const { app, BrowserWindow } = require('electron'); 4 | const url = require('url'); 5 | const path = require('path'); 6 | 7 | let win; 8 | 9 | function createWindow() { 10 | // Create the browser window. 11 | win = new BrowserWindow({ 12 | width: 600, 13 | height: 650, 14 | icon: "client/public/ab_radio_192.png", 15 | }); 16 | 17 | // and load the index.html of the app. 18 | const startUrl = process.env.ELECTRON_START_URL || url.format({ 19 | pathname: path.join(__dirname, './client/build/index.html'), 20 | protocol: 'file:', 21 | slashes: true 22 | }); 23 | win.loadURL(startUrl); 24 | 25 | // Open the DevTools. 26 | if (process.env.ELECTRON_START_URL) win.webContents.openDevTools(); 27 | 28 | // Emitted when the window is closed. 29 | win.on('closed', () => { 30 | // Dereference the window object, usually you would store windows 31 | // in an array if your app supports multi windows, this is the time 32 | // when you should delete the corresponding element. 33 | win = null; 34 | }) 35 | } 36 | 37 | // This method will be called when Electron has finished 38 | // initialization and is ready to create browser windows. 39 | // Some APIs can only be used after this event occurs. 40 | app.on('ready', createWindow); 41 | 42 | // Quit when all windows are closed. 43 | app.on('window-all-closed', () => { 44 | // stop predictors 45 | Config.exit(); 46 | 47 | // On macOS it is common for applications and their menu bar 48 | // to stay active until the user quits explicitly with Cmd + Q 49 | if (process.platform !== 'darwin') { 50 | app.quit() 51 | } 52 | }); 53 | 54 | app.on('activate', () => { 55 | // On macOS it's common to re-create a window in the app when the 56 | // dock icon is clicked and there are no other windows open. 57 | if (win === null) { 58 | createWindow() 59 | } 60 | }); 61 | } -------------------------------------------------------------------------------- /handlers/app.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const express = require('express'); 3 | const app = express(); 4 | app.use(express.json()); // to support JSON-encoded bodies 5 | app.use(express.urlencoded({extended: true})); // to support URL-encoded bodies 6 | const server = http.createServer(app); 7 | 8 | const { log } = require("abr-log")("app"); 9 | const { config } = require("./config"); 10 | 11 | server.listen(config.user.serverPort); // no "localhost" binding in case this routine would be run in a Docker container 12 | 13 | log.info("Server listening on port " + config.user.serverPort); 14 | 15 | const DEV = process.env.DEV; 16 | 17 | //app.use('/', express.static('client/build')); 18 | 19 | if (DEV) { 20 | log.warn('DEV MODE'); 21 | // proxy everything but /config/*, /status/* and /listen/* requests (managed by this program) 22 | // everything else is routed to localhost:3000, the react dev server. 23 | const proxy = require('http-proxy-middleware'); 24 | const apiProxy = proxy('!(/config|/config/**|/status/**|/listen/**|/flag)', { target: 'http://localhost:3000', ws: true, loglevel: 'warn' }); 25 | app.use('/', apiProxy); 26 | 27 | } else { 28 | log.warn('Server started in production mode.'); 29 | //app.use('/login.html', express.static('webmin-src/build/login.html')); 30 | app.use('/', express.static('client/build')); 31 | } 32 | 33 | try { 34 | require('../api/config')(app); 35 | require('../api/content')(app); 36 | require('../api/flag')(app); 37 | require('../api/listen')(app); 38 | require('../api/radios')(app); 39 | require('../api/status')(app); 40 | } catch (e) { 41 | log.warn('API error. e=' + e); 42 | } 43 | 44 | exports.app = app; -------------------------------------------------------------------------------- /handlers/cache.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { log } = require('abr-log')('cache'); 4 | const { Writable } = require("stream"); 5 | const { Analyser } = require("adblockradio"); 6 | 7 | const UNSURE = "unsure"; 8 | 9 | class AudioCache extends Writable { 10 | constructor(options) { 11 | super(); 12 | this.canonical = options.canonical; 13 | this.cacheLen = options.cacheLen; 14 | this.streamInitialBuffer = options.streamInitialBuffer; 15 | this.bitrate = 16000; // bytes per second. default value, to be updated later 16 | this.flushAmount = 60 * this.bitrate; 17 | this.readCursor = null; 18 | this.buffer = Buffer.allocUnsafe(this.cacheLen * this.bitrate + 2*this.flushAmount).fill(0); 19 | this.writeCursor = 0; 20 | } 21 | 22 | setBitrate(bitrate) { 23 | if (!isNaN(bitrate) && bitrate > 0 && this.bitrate != bitrate) { 24 | log.info(this.canonical + " AudioCache: bitrate adjusted from " + this.bitrate + "bps to " + bitrate + "bps"); 25 | 26 | const delta = (this.cacheLen + 2*60) * (bitrate - this.bitrate); 27 | if (bitrate > this.bitrate) { 28 | // if bitrate is higher than expected, expand the buffer accordingly by making room at its right 29 | var expandBuf = Buffer.allocUnsafe(delta).fill(0); 30 | log.info(this.canonical + " AudioCache: buffer expanded from " + this.buffer.length + " to " + (this.buffer.length + delta) + " bytes"); 31 | this.buffer = Buffer.concat([ this.buffer, expandBuf ]); 32 | 33 | } else if (bitrate < this.bitrate) { 34 | // bitrate is lower than expected. shrink the buffer. 35 | // delta is negative here 36 | 37 | // first, shrink at the right of the "writeCursor". 38 | const delta1 = Math.min(this.buffer.length - this.writeCursor, -delta); 39 | if (delta1 > 0) { 40 | this.buffer = this.buffer.slice(0, this.buffer.length - delta1); 41 | } 42 | 43 | // then, if necessary, cut the left of the Buffer. 44 | if (-delta - delta1 > 0) { 45 | this.buffer = this.buffer.slice(-delta - delta1); // remove the first N bytes 46 | this.writeCursor -= -delta - delta1; 47 | } 48 | 49 | log.info(this.canonical + " AudioCache: buffer shrinked from " + this.buffer.length + " to " + (this.buffer.length + delta) + " bytes"); 50 | } 51 | this.bitrate = bitrate; 52 | this.flushAmount = 60 * this.bitrate; 53 | } 54 | } 55 | 56 | _write(data, enc, next) { 57 | if (this.writeCursor + data.length > this.buffer.length) { 58 | log.warn(this.canonical + " AudioCache: _write: buffer overflow wC=" + this.writeCursor + " dL=" + data.length + " bL=" + this.buffer.length); 59 | } 60 | data.copy(this.buffer, this.writeCursor); 61 | this.writeCursor += data.length; 62 | 63 | //log.debug("AudioCache: _write: add " + data.length + " to buffer, new len=" + this.buffer.length); 64 | 65 | if (this.writeCursor >= this.cacheLen * this.bitrate + this.flushAmount) { 66 | //log.debug("AudioCache: _write: cutting buffer at len = " + this.cacheLen * this.bitrate); 67 | this.buffer.copy(this.buffer, 0, this.flushAmount); 68 | this.writeCursor -= this.flushAmount; 69 | 70 | if (this.readCursor) { 71 | this.readCursor -= this.flushAmount; 72 | if (this.readCursor <= 0) this.readCursor = null; 73 | } 74 | } 75 | next(); 76 | } 77 | 78 | readLast(secondsFromEnd, duration) { 79 | var l = this.writeCursor; //this.buffer.length; 80 | if (secondsFromEnd < 0 || duration < 0) { 81 | log.error(this.canonical + " AudioCache: readLast: negative secondsFromEnd or duration"); 82 | return null; 83 | } else if (duration > secondsFromEnd) { 84 | log.error(this.canonical + " AudioCache: readLast: duration=" + duration + " higher than secondsFromEnd=" + secondsFromEnd); 85 | return null; 86 | } else if (secondsFromEnd * this.bitrate > l) { 87 | log.error(this.canonical + " AudioCache: readLast: attempted to read " + secondsFromEnd + " seconds (" + secondsFromEnd * this.bitrate + " b) while bufferLen=" + l); 88 | return null; 89 | } 90 | var data; 91 | if (duration) { 92 | data = this.buffer.slice(l - secondsFromEnd * this.bitrate, l - (secondsFromEnd-duration) * this.bitrate); 93 | this.readCursor = l - (secondsFromEnd-duration) * this.bitrate; 94 | } else { 95 | data = this.buffer.slice(l - secondsFromEnd * this.bitrate); 96 | this.readCursor = l; 97 | } 98 | return data; 99 | } 100 | 101 | readAmountAfterCursor(duration) { 102 | var nextCursor = this.readCursor + duration * this.bitrate; 103 | if (duration < 0) { 104 | log.error(this.canonical + " AudioCache: readAmountAfterCursor: negative duration"); 105 | return null; 106 | } else if (nextCursor > this.writeCursor) { 107 | log.warn(this.canonical + " AudioCache: readAmountAfterCursor: will read until " + this.writeCursor + " instead of " + nextCursor); 108 | } 109 | nextCursor = Math.min(this.writeCursor, nextCursor); 110 | var data = this.buffer.slice(this.readCursor, nextCursor); 111 | this.readCursor = nextCursor; 112 | return data; 113 | } 114 | 115 | getAvailableCache() { 116 | return this.buffer ? Math.max(this.writeCursor / this.bitrate - this.streamInitialBuffer, 0) : 0; 117 | } 118 | } 119 | 120 | class MetaCache extends Writable { 121 | constructor(options) { 122 | super({ objectMode: true }); 123 | this.canonical = options.canonical; 124 | this.meta = {}; 125 | this.cacheLen = options.cacheLen; 126 | } 127 | 128 | _write(meta, enc, next) { 129 | if (!meta.type) { 130 | log.error(this.canonical + " MetaCache: no data type"); 131 | return next(); 132 | } else if (!meta.payload) { 133 | log.warn(this.canonical + " MetaCache: empty " + meta.type + " payload"); 134 | return next(); 135 | } else if (meta.validFrom > meta.validTo) { 136 | log.error(this.canonical + " MetaCache: negative time window validFrom=" + meta.validFrom + " validTo=" + meta.validTo); 137 | return next(); 138 | } else { 139 | //log.debug("MetaCache: _write: " + JSON.stringify(meta)); 140 | } 141 | // events of this kind: 142 | // meta = { type: "metadata", validFrom: Date, validTo: Date, payload: { artist: "...", title : "...", cover: "..." } } ==> metadata for enhanced experience 143 | // meta = { type: "class", validFrom: Date, validTo: Date, payload: "todo" } ==> class of audio, for automatic channel hopping 144 | // meta = { type: "volume", validFrom: Date, validTo: Date, payload: [0.85, 0.89, 0.90, ...] } ==> normalized volume for audio player 145 | // meta = { type: "signal", validFrom: Date, validTo: Date, payload: [0.4, 0.3, ...] } ==> signal amplitude envelope for visualization 146 | 147 | // are stored in the following structure: 148 | // this.meta = { 149 | // "metadata": [ 150 | // { validFrom: ..., validTo: ..., payload: { ... } }, (merges the contiguous segments) 151 | // ... 152 | // ], 153 | // "class": [ 154 | // { validFrom: ..., validTo: ..., payload: ... }, (merges the contiguous segments) 155 | // ... 156 | // ], 157 | // "signal": [ 158 | // { validFrom: ..., validTo: ..., payload: [ ... ] }, 159 | // ... 160 | // ] 161 | // } 162 | 163 | switch (meta.type) { 164 | case "metadata": 165 | case "class": 166 | case "volume": 167 | const curMeta = this.meta[meta.type]; 168 | //log.debug("MetaCache: curMeta=" + JSON.stringify(curMeta)); 169 | if (!curMeta) { 170 | this.meta[meta.type] = [ { validFrom: meta.validFrom, validTo: meta.validTo, payload: meta.payload } ]; 171 | } else { 172 | var samePayload = true; 173 | 174 | for (var key in meta.payload) { 175 | if ("" + meta.payload[key] && "" + meta.payload[key] !== "" + curMeta[curMeta.length-1].payload[key]) { 176 | samePayload = false; 177 | //log.debug("MetaCache: _write: different payload key=" + key + " new=" + meta.payload[key] + " vs old=" + this.meta[meta.type][this.meta[meta.type].length-1].payload[key]); 178 | break; 179 | } 180 | } 181 | if (samePayload) { 182 | this.meta[meta.type][this.meta[meta.type].length-1].validTo = meta.validTo; // extend current segment validity 183 | } else { 184 | this.meta[meta.type][this.meta[meta.type].length-1].validTo = meta.validFrom; // create a new segment 185 | this.meta[meta.type].push({ validFrom: meta.validFrom, validTo: meta.validTo, payload: meta.payload }); 186 | } 187 | } 188 | break; 189 | case "signal": 190 | if (!this.meta[meta.type]) { 191 | this.meta[meta.type] = [ { validFrom: meta.validFrom, validTo: meta.validTo, payload: meta.payload } ]; 192 | } else { 193 | this.meta[meta.type].push({ validFrom: meta.validFrom, validTo: meta.validTo, payload: meta.payload }); 194 | } 195 | break; 196 | default: 197 | log.error(this.canonical + " MetaCache: _write: unknown metadata type = " + meta.type); 198 | } 199 | 200 | // clean old entries 201 | while (+this.meta[meta.type][0].validTo <= +new Date() - 1000 * this.cacheLen) { 202 | this.meta[meta.type].splice(0, 1); 203 | } 204 | 205 | // fix overlapping entries 206 | for (var i=0; i this.meta[meta.type][i+1].validFrom) { 208 | //var middle = (this.meta[meta.type][i].validTo + this.meta[meta.type][i+1].validFrom) / 2; 209 | var delta = (this.meta[meta.type][i].validTo - this.meta[meta.type][i+1].validFrom) / 2; 210 | log.debug(this.canonical + " MetaCache: fix meta " + meta.type + " overlapping prevTo=" + this.meta[meta.type][i].validTo + " nextFrom=" + this.meta[meta.type][i+1].validFrom + " newBound=" + (this.meta[meta.type][i].validTo - delta)); 211 | this.meta[meta.type][i].validTo -= delta; 212 | this.meta[meta.type][i+1].validFrom += delta; 213 | } 214 | } 215 | //log.debug("MetaCache: _write: meta[" + meta.type + "]=" + JSON.stringify(this.meta[meta.type])); 216 | next(); 217 | } 218 | 219 | read(since) { 220 | if (!since) { 221 | this.meta.now = +new Date(); 222 | return this.meta; 223 | } else { 224 | var result = { now: +new Date() }; 225 | var thrDate = result.now - since*1000; 226 | typeloop: 227 | for (var type in this.meta) { 228 | if (type == "now") continue typeloop; 229 | if (thrDate < this.meta[type][0].validFrom) { 230 | result[type] = this.meta[type]; 231 | continue; 232 | } else { 233 | itemloop: 234 | for (var i=0; i= config.user.maxRadios) return callback("Playlist is already full"); 77 | 78 | // now check that the radio is known to our API. 79 | getRadioMetadata(country, name, function(err, radio) { 80 | if (!radio) { 81 | return callback("Radio is not recognized"); 82 | } else { 83 | config.radios.push({ 84 | country: country, 85 | name: name, 86 | content: { 87 | ads: false, 88 | speech: true, 89 | music: true 90 | }, 91 | url: radio.url, 92 | codec: radio.codec, 93 | favicon: radio.favicon, 94 | liveStatus: startMonitoring(country, name, config.user), 95 | }); 96 | //log.debug(JSON.stringify(config.radios[config.radios.length-1])); 97 | saveRadios(); 98 | return callback(null); 99 | } 100 | }); 101 | } 102 | 103 | exports.removeRadio = function(country, name, callback) { 104 | 105 | for (var i=0; i e.country).indexOf(country); 191 | 192 | if (i >= 0) return results[i]; 193 | log.error("getRadioMetadata: radio not found: " + results); 194 | return null; 195 | } catch (e) { 196 | log.warn("Could not get metadata for radio " + country + "_" + name + ". err=" + e); 197 | return null; 198 | } 199 | }*/ 200 | 201 | const saveAvailable = function() { 202 | fs.writeFile("config/available.json", JSON.stringify(config.available, null, '\t'), function(err) { 203 | if (err) { 204 | log.error("saveRadios: could not save available radios config. err=" + err); 205 | } else { 206 | log.debug("saveRadios: list of available radios saved"); 207 | } 208 | }); 209 | } 210 | 211 | const getAvailable = async function() { 212 | // fetch the list of available models on remote model repo 213 | const path = config.user.modelRepo + "list.json"; 214 | try { 215 | const req = await axios.get(path); 216 | config.available = req.data; 217 | } catch (e) { 218 | log.warn('could not get list of available radios at path ' + path + '. e=' + e); 219 | } 220 | saveAvailable(); 221 | } 222 | 223 | getAvailable(); 224 | setInterval(getAvailable, 1000 * 60 * 60); // refresh every hour 225 | 226 | const getAvailableInactive = function() { 227 | 228 | let available = config.available.slice(); 229 | 230 | // remove radios that are currently in playlist 231 | for (let i=available.length-1; i>=0; i--) { 232 | let itemInPlaylist = false; 233 | for (let j=0; j", 19 | "license": "UNLICENSED", 20 | "dependencies": { 21 | "abr-log": "^1.0.2", 22 | "adblockradio": "git+https://github.com/adblockradio/adblockradio.git", 23 | "async": "^2.6.2", 24 | "axios": "^0.18.0", 25 | "express": "^4.16.4", 26 | "fs-extra": "^7.0.1", 27 | "http-proxy-middleware": "^0.19.1", 28 | "stream-tireless-baler": "^1.0.16", 29 | "uuid": "^3.3.2" 30 | }, 31 | "devDependencies": { 32 | "electron": "^3.1.7", 33 | "electron-builder": "^20.39.0", 34 | "electron-packager": "^12.2.0", 35 | "eslint": "^5.15.3" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /records/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/adblockradio/buffer/c7e20e93a9476af1d407099c0dc63cca1ef8e11a/records/.gitkeep --------------------------------------------------------------------------------