From 886cd27d2908d347abfaa5dc05bb246d9aeffd6b Mon Sep 17 00:00:00 2001 From: Alexandre Storelli Date: Fri, 19 Jan 2018 16:05:37 +0100 Subject: [PATCH] add radio playlist edit in UI --- DlFactory.js | 16 +-- client/src/App.js | 119 ++++++++++++----- client/src/DelayCanvas.js | 34 ++++- client/src/Metadata.js | 6 +- client/src/Playlist.js | 135 +++++++++++++++++++ client/src/img/list_241386.svg | 1 + client/src/load.js | 4 +- config.js | 108 ++++++++++++++- handlers.js | 234 --------------------------------- index.js | 198 ++++++++++++++++++++++------ package-lock.json | 6 +- package.json | 2 +- 12 files changed, 532 insertions(+), 331 deletions(-) create mode 100644 client/src/Playlist.js create mode 100644 client/src/img/list_241386.svg delete mode 100644 handlers.js diff --git a/DlFactory.js b/DlFactory.js index 20f7b38..b83e978 100644 --- a/DlFactory.js +++ b/DlFactory.js @@ -6,8 +6,8 @@ log.setLevel("debug"); var cp = require("child_process"); var fs = require("fs"); var { getMeta } = require("webradio-metadata"); -var Dl = require("../adblockradio-dl/dl.js"); -var config = require("./config.js"); +var { StreamDl } = require("../adblockradio-dl/dl.js"); +//var config = require("./config.js"); class Db { constructor(options) { @@ -15,8 +15,8 @@ class Db { this.name = options.name; this.path = options.path; this.ext = options.ext; - this.audioCache = new AudioCache({ bitrate: options.bitrate, cacheLen: config.user.cacheLen }); - this.metaCache = new MetaCache({ cacheLen: config.user.cacheLen }); + this.audioCache = new AudioCache({ bitrate: options.bitrate, cacheLen: options.cacheLen }); + this.metaCache = new MetaCache({ cacheLen: options.cacheLen }); } dirDate(now) { @@ -208,9 +208,9 @@ class MetaCache extends Writable { } else { var samePayload = true; for (var key in meta.payload) { - if (meta.payload[key] !== this.meta[meta.type][this.meta[meta.type].length-1].payload[key]) { + if ("" + meta.payload[key] && "" + meta.payload[key] !== "" + this.meta[meta.type][this.meta[meta.type].length-1].payload[key]) { samePayload = false; - //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]); + 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]); break; } } @@ -249,7 +249,7 @@ class MetaCache extends Writable { } module.exports = function(radio, options) { - var newDl = new Dl({ country: radio.country, name: radio.name, segDuration: options.segDuration }); + var newDl = new StreamDl({ country: radio.country, name: radio.name, segDuration: options.segDuration }); var dbs = null; newDl.on("error", function(err) { log.error("dl err=" + err); @@ -258,7 +258,7 @@ module.exports = function(radio, options) { //metadataCallback(metadata); radio.url = metadata.url; radio.favicon = metadata.favicon; - var db = new Db({ country: radio.country, name: radio.name, ext: metadata.ext, bitrate: metadata.bitrate, path: __dirname }); + var db = new Db({ country: radio.country, name: radio.name, ext: metadata.ext, bitrate: metadata.bitrate, cacheLen: options.cacheLen, path: __dirname }); log.debug("DlFactory: " + radio.country + "_" + radio.name + " metadata=" + JSON.stringify(metadata)); newDl.on("data", function(dataObj) { diff --git a/client/src/App.js b/client/src/App.js index 92fd7dc..d4d398b 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -5,6 +5,7 @@ import './App.css'; import Radio from './Radio.js'; import Metadata from './Metadata.js'; import DelayCanvas from './DelayCanvas.js'; +import Playlist from './Playlist.js'; import { load, refreshMetadata, HOST } from './load.js'; import { play, stop } from './audio.js'; @@ -15,6 +16,7 @@ import classNames from 'classnames'; import iconPlay from "./img/start_1279169.svg"; import iconStop from "./img/stop_1279170.svg"; +import iconList from "./img/list_241386.svg"; //import iconNext from "./img/next_607554.svg"; class App extends Component { @@ -25,57 +27,67 @@ class App extends Component { config: [], playingRadio: null, playingDate: null, - clockDiff: 0 + clockDiff: 0, + playlistEditMode: false } this.play = this.play.bind(this); this.seekBackward = this.seekBackward.bind(this); this.seekForward = this.seekForward.bind(this); + this.switchPlaylistEditMode = this.switchPlaylistEditMode.bind(this); + this.refreshMetadataContainer = this.refreshMetadataContainer.bind(this); + this.refreshConfig = this.refreshConfig.bind(this); + this.insertRadio = this.insertRadio.bind(this); + this.removeRadio = this.removeRadio.bind(this); } componentDidMount() { - let self = this; + this.refreshConfig(); + this.timerID = setInterval(() => this.tick(), 300); + } - var refreshMetadataContainer = function() { - var stateChange = {}; - var f = function(iradio, callback) { - if (iradio >= self.state.config.radios.length) return callback(); - var radio = self.state.config.radios[iradio].country + "_" + self.state.config.radios[iradio].name; - refreshMetadata(radio, function(metadata) { - for (var type in metadata) { - if (type === "now") { - stateChange.clockDiff = +new Date() - metadata.now; - } else { - metadata[type][metadata[type].length-1].validTo = null; - stateChange[radio + "|" + type] = metadata[type].reverse(); - } + componentWillUnmount() { + clearInterval(this.timerID); + clearInterval(this.metadataTimerID); + } + + refreshMetadataContainer() { + var self = this; + var stateChange = {}; + var f = function(iradio, callback) { + if (iradio >= self.state.config.radios.length) return callback(); + var radio = self.state.config.radios[iradio].country + "_" + self.state.config.radios[iradio].name; + refreshMetadata(radio, function(metadata) { + for (var type in metadata) { + if (type === "now") { + stateChange.clockDiff = +new Date() - metadata.now; + } else { + metadata[type][metadata[type].length-1].validTo = null; + stateChange[radio + "|" + type] = metadata[type].reverse(); } - f(iradio+1, callback); - }); - } - f(0, function() { - self.setState(stateChange); + } + f(iradio+1, callback); }); } + f(0, function() { + self.setState(stateChange); + }); + } + refreshConfig() { + var self = this; load("/config?t=" + Math.round(Math.random()*1000000), function(res) { try { var config = JSON.parse(res); console.log(config); self.setState({ config: config }, function() { - self.metadataTimerID = setInterval(refreshMetadataContainer, 2000); - refreshMetadataContainer(); + self.metadataTimerID = setInterval(self.refreshMetadataContainer, 2000); + self.refreshMetadataContainer(); }); } catch(e) { console.log("problem parsing JSON from server: " + e.message); } self.setState({ configLoaded: true }); }); - this.timerID = setInterval(function() { self.tick() }, 300); - } - - componentWillUnmount() { - clearInterval(this.timerID); - clearInterval(this.metadataTimerID); } tick() { @@ -87,7 +99,16 @@ class App extends Component { //var delay = +new Date() - this.state.clockDiff - (date ? new Date(date) : new Date(); //var secondsDelay = Math.round(delay/1000); if (!radio) radio = this.state.playingRadio; - if (!delay) delay = 0; + if (!delay) { + var startAvailable = new Date(); + for (var i=0; i self.play(null, null)} /> @@ -189,32 +229,39 @@ class App extends Component { return ( - + {config.radios.map(function(radio, i) { var playing = self.state.playingRadio === radio.country + "_" + radio.name; return ( ) })} - {self.state.playingRadio && + {self.state.playingRadio && !self.state.playlistEditMode && } + {self.state.playlistEditMode && + + } {status} {buttons} - {/**/} diff --git a/client/src/DelayCanvas.js b/client/src/DelayCanvas.js index 743b130..4e44500 100644 --- a/client/src/DelayCanvas.js +++ b/client/src/DelayCanvas.js @@ -3,7 +3,7 @@ import React, { Component } from "react"; import PropTypes from "prop-types"; //import styled from "styled-components"; -import classNames from 'classnames'; +//import classNames from 'classnames'; class DelayCanvas extends Component { @@ -36,6 +36,7 @@ class DelayCanvas extends Component { } getCursorPosition(event) { + if (this.props.inactive) return; var rect = this.refs.canvas.getBoundingClientRect(); var x = event.clientX - rect.left; @@ -49,14 +50,38 @@ class DelayCanvas extends Component { this.play(newDelay); } + getAvailableCache() { + var startAvailable = new Date(); + for (var i=0; i= self.props.maxItems)) return null //var playerTime = +self.props.playingDate; - var playing = +item.validFrom - 1000 <= (self.props.date-self.props.playingDelay) && (!item.validTo || (self.props.date-self.props.playingDelay) < +item.validTo - 1000); + var playing = false; + if (self.props.date) { + playing = +item.validFrom - 1000 <= (self.props.date-self.props.playingDelay) && (!item.validTo || (self.props.date-self.props.playingDelay) < +item.validTo - 1000); + } //console.log("playing=" + playing + " start=" + item.start + " date=" + playerTime + " stop=" + item.end); return ( @@ -60,6 +63,7 @@ Metadata.propTypes = { playingRadio: PropTypes.string.isRequired, playCallback: PropTypes.func.isRequired, metaList: PropTypes.array, + date: PropTypes.object, maxItems: PropTypes.number }; diff --git a/client/src/Playlist.js b/client/src/Playlist.js new file mode 100644 index 0000000..07611a5 --- /dev/null +++ b/client/src/Playlist.js @@ -0,0 +1,135 @@ +// Copyright (c) 2018 Alexandre Storelli + +import React, { Component } from "react"; +import PropTypes from "prop-types"; +import styled from "styled-components"; +import { load } from './load.js'; +import classNames from 'classnames'; +import defaultCover from "./img/default_radio_logo.svg"; + +class Playlist extends Component { + constructor(props) { + super(props); + this.insert = this.insert.bind(this); + this.remove = this.remove.bind(this); + this.state = { + radiosLoaded: false, + radios: [] + } + } + + componentDidMount() { + let self = this; + + load("/config/radios/available?t=" + Math.round(Math.random()*1000000), function(res) { + try { + var radios = JSON.parse(res); + self.setState({ radios: radios }); + } catch(e) { + console.log("problem parsing JSON from server: " + e.message); + } + self.setState({ radiosLoaded: true }); + }); + } + + insert(country, name) { + this.props.insertRadio(country, name); + } + + remove(country, name) { + this.props.removeRadio(country, name); + } + + render() { + var self = this; + if (!this.state.radiosLoaded) { + return ( +

Loading...

+ ); + } + + var current = this.props.config.radios; + var available = this.state.radios; + var playlistFull = this.props.config.radios.length >= this.props.config.user.maxRadios; + + return ( + + Your current favorites : click to remove + {current.map(function(radio, i) { + return ( + + + {radio.name} + + + + ) + })} + {!playlistFull ? + More radios : click to add + : + More radios : make room in your favorites, then click to add + } + {available.map(function(radio, i) { + return ( + + + {radio.name} + + + + ) + })} + + ) + } +} + +Playlist.propTypes = { + config: PropTypes.object.isRequired, + insertRadio: PropTypes.func.isRequired, + removeRadio: PropTypes.func.isRequired +}; + +const PlaylistContainer = styled.div` + flex-grow: 1; + overflow-y: scroll; +`; + +const PlaylistSectionTitle = styled.h3` + text-align: center; +`; + +const PlaylistItem = styled.div` + border: 2px solid #eee; + border-radius: 10px; + margin: 10px; + padding: 10px; + flex-shrink: 0; + background: #eee; + display: flex; + cursor: not-allowed; + + &.active { + cursor: pointer; + } +`; + +const PlaylistItemText = styled.p` + flex-grow: 1; + align-self: center; +` + +const PlaylistItemLogo = styled.img` + width: 60px; + height: 60px; + align-self: center; + margin-left: 10px; +`; + +export default Playlist; + +/*&.playing { + width: 60px; + height: 60px; +}*/ diff --git a/client/src/img/list_241386.svg b/client/src/img/list_241386.svg new file mode 100644 index 0000000..90246ed --- /dev/null +++ b/client/src/img/list_241386.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/load.js b/client/src/load.js index 2083cb9..b7a4709 100644 --- a/client/src/load.js +++ b/client/src/load.js @@ -1,5 +1,5 @@ -const HOST = "http://localhost:9820"; -//const HOST = "https://bufferapi.s00.adblockradio.com" +//const HOST = "http://localhost:9820"; +const HOST = "https://bufferapi.s00.adblockradio.com" exports.load = function(path, callback) { var xhttp = new XMLHttpRequest(); diff --git a/config.js b/config.js index babb132..76ce34b 100644 --- a/config.js +++ b/config.js @@ -1,5 +1,10 @@ +"use strict"; + var log = require("loglevel"); var fs = require("fs"); +var { getRadioMetadata } = require("../adblockradio-dl/dl.js"); +var { getAvailable } = require("webradio-metadata"); + // list of listened radios: var config = new Object(); @@ -12,4 +17,105 @@ try { return log.error("cannot load config. err=" + e); } -module.exports = config; +exports.config = config; + +var isRadioInConfig = function(country, name) { + var isAlreadyThere = false; + for (var i=0; i= config.user.maxRadios) return callback("Playlist is already full"); + + // now check that the radio is known to our API. + getRadioMetadata(country, name, function(err, radio) { + if (!radio) { + return callback("Radio is not recognized"); + } else { + config.radios.push({ + "country": country, + "name": name, + "content": { + "ads": true, + "speech": true, + "music": true + }, + "enable": true + }); + saveRadios(); + return callback(null); + } + }); +} + +var removeRadio = function(country, name, callback) { + + for (var i=0; i=0; i--) { + let itemInPlaylist = false; + for (let j=0; j 0) { - state.nSegmentsInitialBuffer--; - filesCallback(); - } else { - var delay = +SEG_DURATION*1000 - (new Date()-state.lastSentDate); - log.debug("listenHandler: send more data in " + delay + " ms"); - setTimeout(filesCallback, delay); - } - } - }, function(err) { - if (err) { - log.error("listen: err=" + err.message); - callback(); - } else { - listenHandler(response, radio, before, state, callback); - } - }); - }); -}*/ - -//const STREAM_INITIAL_BUFFER = 8; // send N seconds at connection. -//const STREAM_GRANULARITY = 1; // send N seconds of data every N seconds. -var listenRequestDate = null; -exports.listenHandler = function(response, radio, delay, state, callback) { - var initialBuffer = getRadio(radio).liveStatus.audioCache.readLast(+delay+config.user.streamInitialBuffer,config.user.streamInitialBuffer); - log.info("listen: send initial buffer of " + initialBuffer.length + " bytes"); - response.write(initialBuffer); - - var listenTimer = setInterval(function() { - if (state.newRequest) { - listenRequestDate = state.requestDate; - state.newRequest = false; - } else if (listenRequestDate !== state.requestDate) { - log.warn("request canceled because another one has been initiated"); - clearInterval(listenTimer); - return callback(); - } - var willWaitDrain = !response.write(""); - if (!willWaitDrain) { // detect congestion of stream - sendMore(); - } else { - log.debug("listenHandler: will wait for drain event"); - - var drainCallback = function() { - clearTimeout(timeoutMonitor); - sendMore(); - } - response.once("drain", drainCallback); - var timeoutMonitor = setTimeout(function() { - response.removeListener("drain", drainCallback); - clearInterval(listenTimer); - log.error("listenHandler: drain event not emitted, connection timeout"); - callback(); - }, config.user.streamGranularity*1500); - } - }, 1000*config.user.streamGranularity); - var sendMore = function() { - response.write(getRadio(radio).liveStatus.audioCache.readAmountAfterCursor(config.user.streamGranularity)); - } -} - -/*exports.metadataHandler = function(response, radio, after) { - findDataFiles({ radios: [ radio ], after: after, path: __dirname }, function(classes) { - //log.debug(classes); // very verbose - var list = {}; - for (var classItem in classes) { - Object.assign(list, classes[classItem]); - } - var files = Object.keys(list); - files.sort(); - //response.json(list); - var isSameSegment = function(curSegment, newData) { - if (curSegment.class !== newData.class) { - //log.debug("isSameSegment: curEnd=" + curSegment.end + " diff class cur=" + curSegment.class + " vs new=" + newData.class); - return false; - } else if (!curSegment.title || !newData.title) { - //log.debug("isSameSegment: curEnd=" + curSegment.end + " no title"); - return true; - } else if (curSegment.title.artist && newData.title.artist && curSegment.title.artist !== newData.title.artist) { - //log.debug("isSameSegment: curEnd=" + curSegment.end + " diff artist cur=" + curSegment.title.artist + " vs new=" + newData.title.artist); - return false; - } else if (curSegment.title.title && newData.title.title && curSegment.title.title !== newData.title.title) { - //log.debug("isSameSegment: curEnd=" + curSegment.end + " diff title cur=" + curSegment.title.title + " vs new=" + newData.title.title); - return false; - } else if (curSegment.title.cover && newData.title.cover && curSegment.title.cover !== newData.title.cover) { - //log.debug("isSameSegment: curEnd=" + curSegment.end + " diff cover"); - return false; - } else { - //log.debug("isSameSegment: same segment"); - return true; - } - } - var newSegment = function(index) { - return { class: list[files[index]].class, start: getDateFromPath(files[index]), end: getDateFromPath(files[index]), title: null } - } - - var result = []; - if (files.length == 0) return response.json(result); - var curSegment = newSegment(0); - async.forEachOfSeries(files, function(file, index, filesCallback) { - //log.debug("metadata: read=" + file + ".json"); - - curSegment.end = getDateFromPath(file); - - if (isLive(radio, file)) { - //log.info("listenHandler: currentAudioFile"); - var pData = { class: list[file].class, title: getRadio(radio).liveStatus.metadata }; - if (!isSameSegment(curSegment, pData)) { - result.push(curSegment); - curSegment = newSegment(index); - } - curSegment.title = pData.title; - filesCallback(); - } else { - fs.readFile(file + ".json", function(err, data) { - if (err) log.error("metadata: readFile err=" + err); - try { - var pData = JSON.parse(data); - } catch(e) { - log.error("metadata: json parse error file=" + file + ".json"); - return filesCallback(); - } - pData.class = list[file].class; - if (!isSameSegment(curSegment, pData)) { - result.push(curSegment); - curSegment = newSegment(index); - } - curSegment.title = pData.title; - filesCallback(); - }); - } - - - }, function(err) { - if (err) log.error("metadata: err=" + err.message); - result.push(curSegment); - response.set({ 'Access-Control-Allow-Origin': '*' }); - response.json(result); - }); - }); -}*/ diff --git a/index.js b/index.js index 6b683f3..7da5176 100644 --- a/index.js +++ b/index.js @@ -11,23 +11,49 @@ var findDataFiles = require("./findDataFiles.js"); var async = require("async"); var DlFactory = require("./DlFactory.js"); -const DL = true; const FETCH_METADATA = true; const SEG_DURATION = 10; // in seconds const LISTEN_BUFFER = 30; // in seconds -var config = require("./config.js"); +var { config, getRadios, insertRadio, removeRadio, getAvailableInactive } = require("./config.js"); -if (DL) { - var dl = []; +var dl = []; +var updateDlList = function() { + // add missing sockets for (var i=0; i=0; j--) { + var shouldBeThere = false; + for (var i=0; i