From c1c49db7c9d491b9a141bc5f06613335c87a3f31 Mon Sep 17 00:00:00 2001 From: Caley Date: Sun, 16 Apr 2023 18:14:07 -0500 Subject: [PATCH] fix(add_sqlite3_for_persistent_storage): decoupled frontend and backend with regards to the playlist, so playlist runs in the node-server and emits messages to the frontend when the playlist changes --- app/playlist.js | 145 +++++++++++ db/api/router.js | 4 +- .../{playlist-controllers.js => playlist.js} | 77 ++++-- package.json | 1 + server.js | 3 +- src/App.js | 242 +++++++++--------- src/PatternView.js | 9 +- src/utils.js | 50 ++++ yarn.lock | 5 + 9 files changed, 381 insertions(+), 155 deletions(-) create mode 100644 app/playlist.js rename db/controllers/{playlist-controllers.js => playlist.js} (62%) create mode 100644 src/utils.js diff --git a/app/playlist.js b/app/playlist.js new file mode 100644 index 0000000..c7cf68a --- /dev/null +++ b/app/playlist.js @@ -0,0 +1,145 @@ +const _ = require("lodash"); +const {discoveries} = require("./discovery"); +const {getPlaylistFromDB} = require("../db/controllers/playlist"); + +const WebSocket = require('ws'); +const http = require('http'); +const { + v4: uuidv4, +} = require('uuid'); + +const playlist = {}; +exports.playlist = playlist; + +let currentPlaylist = [] +let currentPlaylistData = [] +let pixelBlazeData = [] +let pixelBlazeIds = [] +let playlistTimeout = null +let playlistLoopTimeout = null +let initInterval = null +init = () => { + getPlaylistFromDB() + .then((data) => { + currentPlaylist = [] // resetting current play so it doesn't grow to infinity + currentPlaylist.push(...data) // adding new playlist items to list + if (JSON.stringify(currentPlaylist) !== JSON.stringify(currentPlaylistData)) { + currentPlaylistData = [] + currentPlaylistData.push(...data) + } + }) + .catch('there was an error gathering playlist details') + + // gather pixelblaze data + pixelBlazeData = _.map(discoveries, function (v, k) { + let res = _.pick(v, ['lastSeen', 'address']); + _.assign(res, v.controller.props); + return res; + }) + pixelBlazeIds = _.map(pixelBlazeData, 'id') +} + +initInterval = setInterval(init ,100) + +const server = http.createServer(); +const port = 1890; +const playlistServer = new WebSocket.Server({server}); +server.listen(port, () => { + console.log(`Playlist server is running on port ${port}`); +}); + +const playlistClients = {}; + +playlistServer.on('connection', (connection) => { + // Generate a unique code for every user + const clientId = uuidv4() + console.log(`Recieved a new connection.`); + + // Store the new connection and handle messages + playlistClients[clientId] = connection; + console.log(`${clientId} connected.`); + connection.on('message', async (data) => { + let message + try { + message = JSON.parse(data); + } catch (err) { + sendError(playlistServer, `Wrong format ${err}`) + return + } + if (message.type === 'LAUNCH_PLAYLIST_NOW') { + console.log('received launch playlist now message!') + await runPlaylistLoopNow() + } + }) +}); + +const sendError = (ws, message) => { + const messageObject = { + type: 'ERROR', + payload: message, + }; + ws.send(JSON.stringify(messageObject)); +}; +const broadcastMessage = (json) => { + const data = JSON.stringify(json); + for(let userId in playlistClients) { + let playlistClient = playlistClients[userId]; + if(playlistClient.readyState === WebSocket.OPEN) { + playlistClient.send(data); + } + }; +}; +const sendPattern = (pattern) => { + const name = pattern.name + _.each(pixelBlazeIds, async id => { + id = String(id); + let controller = discoveries[id] && discoveries[id].controller; + if (controller) { + const command = { + programName: pattern.name + } + await controller.setCommand(command); + } + }) + let message = { + currentRunningPattern: name, + currentPlaylist: currentPlaylist + } + broadcastMessage(message) +} + +const delaySendPattern = (pattern) => { + return new Promise((resolve) => { + resolve(sendPattern(pattern)) + }) +} +const iterateOnPlaylist = async () => { + for (let index = 0; index < currentPlaylist.length; index++) { + const pattern = currentPlaylist[index] + await delaySendPattern(pattern) + await new Promise(resolve => { + playlistTimeout = setTimeout(resolve, pattern.duration * 1000) + }); + } +} +module.exports.playlistLoop = async () => { + while (true) { + await new Promise(resolve => { + playlistLoopTimeout = setTimeout(resolve, 100) + }); + if(pixelBlazeIds.length) { + await iterateOnPlaylist() + } + initInterval = null + playlistTimeout = null + playlistLoopTimeout = null + } +} +const runPlaylistLoopNow = async () => { + clearInterval(initInterval) + clearInterval(playlistTimeout) + clearInterval(playlistLoopTimeout) + + await this.playlistLoop() +} +this.playlistLoop() diff --git a/db/api/router.js b/db/api/router.js index 5689e63..d10c22f 100644 --- a/db/api/router.js +++ b/db/api/router.js @@ -1,10 +1,10 @@ -const playlistDbRoutes = require('../controllers/playlist-controllers') +const playlistDbRoutes = require('../controllers/playlist') // Create router module.exports = function (app) { app.get('/playlist/getPatterns', playlistDbRoutes.getAllPlaylistPatterns) app.post('/playlist/addPattern', playlistDbRoutes.addPatternToPlaylist) - app.put('/playlist/removePattern', playlistDbRoutes.removePatternToPlaylist) + app.put('/playlist/removePattern', playlistDbRoutes.removePatternFromPlaylist) app.put('/playlist/newPlaylist', playlistDbRoutes.newPlaylist) } diff --git a/db/controllers/playlist-controllers.js b/db/controllers/playlist.js similarity index 62% rename from db/controllers/playlist-controllers.js rename to db/controllers/playlist.js index 579982f..4020d27 100644 --- a/db/controllers/playlist-controllers.js +++ b/db/controllers/playlist.js @@ -1,10 +1,27 @@ const knex = require('../db') +const _ = require("lodash"); +const playlist_table = 'playlist' -exports.getAllPlaylistPatterns = async (req, res) => { - knex +exports.doesPatternExistInPlaylist = async (req) => { + return await knex + .select('*') + .from(playlist_table) + .where('name', "=", req.body.name) + .then((res) => { + if (res.length === 0) return false + if (res.length !== 0) return true + }) +} +exports.getPlaylistFromDB = async () => { + return await knex .select('*') - .from('playlist') - .then(playlistData => { + .from(playlist_table) + .then((data) => { + return data + }) +} +exports.getAllPlaylistPatterns = async (req, res) => { + this.getPlaylistFromDB().then(playlistData => { res.status(200) .json(playlistData); }) @@ -15,21 +32,17 @@ exports.getAllPlaylistPatterns = async (req, res) => { } exports.addPatternToPlaylist = async (req, res) => { - const doesPatternExistInPlaylist = await knex - .select('*') - .from('playlist') - .where('name', "=", req.body.name) - .then((res) => { - if(res.length === 0) return false - if(res.length !== 0) return true + const doesPatternExistInPlaylist = await this.doesPatternExistInPlaylist(req) + .then((condition) => { + return condition }) // update existing pattern in playlist if(doesPatternExistInPlaylist) { - knex + await knex .update({ duration: req.body.duration, }) - .into('playlist') + .into(playlist_table) .where( 'name', '=', req.body.name ) @@ -42,12 +55,12 @@ exports.addPatternToPlaylist = async (req, res) => { } // insert new pattern into playlist if(!doesPatternExistInPlaylist) { - knex + await knex .insert({ name: req.body.name, duration: req.body.duration, }) - .into('playlist') + .into(playlist_table) .then(() => { res.status(200) .json({message: `Pattern \'${req.body.name}\' with a duration of ${req.body.duration} created.`}) @@ -59,9 +72,9 @@ exports.addPatternToPlaylist = async (req, res) => { } } -exports.removePatternToPlaylist = async (req, res) => { - knex - .into('playlist') +exports.removePatternFromPlaylist = async (req, res) => { + await knex + .into(playlist_table) .where('name', req.body.name) .del() .then( () => { @@ -78,15 +91,27 @@ exports.removePatternToPlaylist = async (req, res) => { } exports.newPlaylist = async (req, res) => { - await knex - .into('playlist') - .where('id','!=', 'null') - .del() + await knex.transaction(async trx => { + //clear table first + await knex + .into(playlist_table) + .where('id','!=', 'null') + .del() + .transacting(trx); + // insert new pattern + await knex + .insert({ + name: req.body.name, + duration: req.body.duration, + }) + .into(playlist_table) + .transacting(trx); + }) .then( () => { - res.status(200) - .json({ message: `Creating a new playlist with pattern '${req.body.name}' from playlist.`}); - } - ) + res.status(200) + .json({ message: `Creating a new playlist with pattern '${req.body.name}' from playlist.`}); + } + ) .catch(err => { res.status(500) .json({ diff --git a/package.json b/package.json index 905a157..1a0649c 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "react-dom": "^16.13.1", "react-scripts": "^3.4.1", "sqlite3": "^5.1.6", + "uuid": "^9.0.0", "ws": "^7.4.6" }, "scripts": { diff --git a/server.js b/server.js index 79f871a..2a36cf2 100644 --- a/server.js +++ b/server.js @@ -4,7 +4,7 @@ const app = express() const bodyParser = require('body-parser'); const compression = require('compression'); const repl = require('repl'); - +require("./app/playlist"); discovery.start({ host: '0.0.0.0', port: 1889 @@ -26,6 +26,7 @@ app.listen(PORT) const r = repl.start('> '); + r.on('exit', () => { console.log('Received "exit" event from repl!'); process.exit(); diff --git a/src/App.js b/src/App.js index 84a5954..343e88d 100644 --- a/src/App.js +++ b/src/App.js @@ -1,6 +1,7 @@ import React, {Component} from 'react'; import './App.css'; import _ from 'lodash'; +import {check, connect, sendMessage, ws} from "./utils"; import PatternView from './PatternView' @@ -11,30 +12,47 @@ class App extends Component { discoveries: [], groups: [], // Currently duscovered patterns and their controllers runningPatternName: null, + message: [], + newPlaylist: [], playlist: [], // Browser-persisted single playlist singleton playlistIndex: 0, playlistDefaultInterval: 15, cloneSource: null, cloneDest: {}, cloneInProgress: false, - showDevControls: false + showDevControls: false, + ws: null } - this.getPlaylistFromDb() - .then((playlistResults) => { - this.state.playlist = playlistResults; - }) + if (this.state.playlist.length) { this.state.playlistDefaultInterval = _(this.state.playlist).last().duration } this.poll = this.poll.bind(this); - this._playlistInterval = null - this.cloneDialogRef = React.createRef(); + }; + + filterMessage(){ + const message = this.state.message + if(message) { + message.filter((item) => { + this.setState({ + runningPatternName: (this.state.runningPatternName === item.currentRunningPattern) ? this.state.runningPatternName : item.currentRunningPattern, + playlist: JSON.parse((JSON.stringify(this.state.playlist) === JSON.stringify(item.currentPlaylist)) ? JSON.stringify(this.state.playlist) : JSON.stringify(item.currentPlaylist)), + }) + return this + }) + } + } + receiveMessage(message) { + if (message) { + this.setState({ + message: [JSON.parse(message.data)] + }) + } + this.filterMessage() } - - // come back to this later, it would be ideal to share this around... async playlistAPIRequest(method, body?, route) { const payload = { method: method, @@ -43,34 +61,22 @@ class App extends Component { 'Content-Type': 'application/json' } } - if (method !== 'GET') payload[body] = body - - const playlist = await fetch(route, payload) - .then((res) => { - return res.json(); - }) - console.log(playlist) - return playlist - } - async getPlaylistFromDb() { - const payload = { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - } + if (method !== 'GET') payload['body'] = body + try { + const result = await fetch(route, payload) + .then((res) => { + return res.json(); + }) + return result + } catch(err) { + console.warn(`unable to fetch request for some reason ${err}`) } - const playlist = await fetch('./playlist/getPatterns', payload) - .then((res) => { - return res.json(); - }) - return playlist } async poll() { if (this.interval) clearTimeout(this.interval); - let res = await fetch('./discover') try { + let res = await fetch('./discover') let discoveries = await res.json(); let groupByName = {}; @@ -93,26 +99,33 @@ class App extends Component { .sortBy('name') .value(); // console.log("groups", groups); - discoveries = _.sortBy(discoveries, "name") this.setState({discoveries, groups}) } catch (err) { this.setState({err}) } + check() if (!this.unmounting) this.interval = setTimeout(this.poll, 1000) } + async componentDidMount() { document.addEventListener("keydown", this._handleKeyDown); await this.poll() - if (this.state.playlist.length) this._launchPatternAndSetTimeout() + // connecting to playlist websocket server + connect(); + // attaching websocket event listener to receive messages + ws.addEventListener('message', (event) => {this.receiveMessage(event)}); + // kicking off playlist event loops on page load + this._launchPlaylistNow() } componentWillUnmount() { this.unmounting = true; clearInterval(this.interval) - clearInterval(this._playlistInterval) + ws.close() + ws.removeEventListener('message', (event) => {this.receiveMessage(event)}); } async _launchPattern(pattern) { @@ -120,82 +133,51 @@ class App extends Component { console.warn(`pattern ${pattern.name} is already running, ignoring launch request`) return } - // console.log('launching pattern', pattern) - return new Promise((resolve) => { - this.setState({ runningPatternName: pattern.name }, () => { - const payload = { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - command: { - programName: pattern.name - }, - ids: _.map(pattern.pixelblazes, 'id') - }) - } - resolve(fetch('./command', payload)) - }) - }) + this._launchPlaylistNow() } + _handlePatternClick = async (event, pattern) => { event.preventDefault() await this._startNewPlaylist(pattern) } - storePlaylist = (patternNameToBeRemoved?: string, addNewPlaylist?: Object) => { - if (!patternNameToBeRemoved) { - this.state.playlist.map((pattern) => { - return new Promise((resolve) => { - const payload = { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - name: pattern.name, - duration: pattern.duration - }) - } - resolve(fetch('./playlist/addPattern', payload)) + storePlaylist = (patternNameToBeRemoved?: string, addNewPlaylist?: Object, mode) => { + if (patternNameToBeRemoved === null && mode === "update") { + console.log('trying to add to existing playlist') + // add pattern name to existing playlist + this.state.newPlaylist.map((pattern) => { + const body = JSON.stringify({ + name: pattern.name, + duration: pattern.duration }) + return this.playlistAPIRequest('POST',body, './playlist/addPattern') + .then((playlistResults) => { + return playlistResults; + }) }) } - if (patternNameToBeRemoved) { - return new Promise((resolve) => { - const payload = { - method: 'PUT', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - name: patternNameToBeRemoved - }) - } - resolve(fetch('./playlist/removePattern', payload)) + if (patternNameToBeRemoved && mode === "remove") { + // remove pattern name from playlist + const body = JSON.stringify({ + name: patternNameToBeRemoved }) - } - if (addNewPlaylist) { - // handle new playlist here too - return new Promise((resolve) => { - const payload = { - method: 'PUT', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - name: addNewPlaylist[0].name, - duration: addNewPlaylist[0].duration + return this.playlistAPIRequest('PUT', body, './playlist/removePattern') + .then((playlistResults) => { + return playlistResults; }) - } - resolve(fetch('./playlist/newPlaylist', payload)) + } + if (addNewPlaylist && mode === "create") { + // create a new playlist with a new pattern + const body = JSON.stringify({ + name: addNewPlaylist.name, + duration: addNewPlaylist.duration }) + this.playlistAPIRequest('PUT', body, './playlist/newPlaylist') + .then((playlistResults) => { + return playlistResults; + }) + this._launchPlaylistNow() } } @@ -203,13 +185,17 @@ class App extends Component { event.preventDefault() const newValidDuration = parseFloat(newDuration) || 0 const { playlist } = this.state - const newPlaylist = playlist.slice() - _(newPlaylist).find(['name', pattern.name]).duration = newValidDuration + const newTempPlaylist = playlist.slice() + _(newTempPlaylist).find(['name', pattern.name]).duration = newValidDuration this.setState({ - playlist: newPlaylist, + newPlaylist: newTempPlaylist, playlistDefaultInterval: newValidDuration - }, this.storePlaylist) + }, () => { + setTimeout(() => { + this.storePlaylist(null, null, "update") + }, 10) + }) } _handleAddClick = async (event, pattern) => { @@ -219,44 +205,53 @@ class App extends Component { if (clickedPlaylistIndex === -1) { if (!playlist.length) { await this._startNewPlaylist(pattern) + } else { - // console.log(`adding pattern ${pattern.name} to playlist`) - const newPlaylist = playlist.slice() - newPlaylist.push({ name: pattern.name, duration: playlistDefaultInterval }) - this.setState({ playlist: newPlaylist }, this.storePlaylist) + console.log(`adding pattern ${pattern.name} to playlist`) + const newTempPlaylist = playlist.slice() + newTempPlaylist.push({ name: pattern.name, duration: playlistDefaultInterval }) + this.setState( + { newPlaylist: newTempPlaylist }, () => { + setTimeout(() => { + this.storePlaylist(null, null, "update") + }, 10) + }) + this._launchPlaylistNow() } } else { if (clickedPlaylistIndex !== playlistIndex) { - // console.log(`removing pattern ${pattern.name} from playlist`) - const newPlaylist = playlist.slice() - newPlaylist.splice(clickedPlaylistIndex, 1) - this.setState({ playlist: newPlaylist }, - () => {this.storePlaylist(pattern.name)} - ) + console.log(`removing pattern ${pattern.name} from playlist`) + const newTempPlaylist = playlist.slice() + newTempPlaylist.splice(clickedPlaylistIndex, 1) + setTimeout(() => { + const name = pattern.name + this.storePlaylist(name,null, "remove") + }, 100) + this._launchPlaylistNow() } } } async _startNewPlaylist(startingPattern) { - clearInterval(this._playlistInterval) - const newPlaylist = [{ name: startingPattern.name, duration: this.state.playlistDefaultInterval }] + const newTempPlaylist = { name: startingPattern.name, duration: this.state.playlistDefaultInterval } + console.log(newTempPlaylist) this.setState({ - playlist: newPlaylist, + playlist: newTempPlaylist, playlistIndex: 0 }, () => { - this.storePlaylist(null, newPlaylist) - this._launchPatternAndSetTimeout() + setTimeout(() => { + this.storePlaylist(null, newTempPlaylist, "create"); + }, 100) }) } - + _launchPlaylistNow() { + const message = { + type: 'LAUNCH_PLAYLIST_NOW' + } + sendMessage(message) + } async _launchPatternAndSetTimeout() { await this._launchCurrentPattern() - const { playlist, playlistIndex } = this.state - this._playlistInterval = setTimeout(() => { - const { playlist, playlistIndex } = this.state - const nextIndex = (playlistIndex + 1) % playlist.length - this.setState({ playlistIndex: nextIndex }, () => this._launchPatternAndSetTimeout()) - }, playlist[playlistIndex].duration * 1000) } async _launchCurrentPattern() { @@ -432,6 +427,7 @@ class App extends Component {
{cloneDialog} +

Patterns

{this.state.groups.map((pattern) => { diff --git a/src/PatternView.js b/src/PatternView.js index 38587c1..8fa92a4 100644 --- a/src/PatternView.js +++ b/src/PatternView.js @@ -22,7 +22,8 @@ class PatternView extends Component { placeholder="0" pattern="[\d\.]+" value={duration} - onChange={this._handleDurationChange} /> + onChange={this._handleDurationChange} + />
@@ -49,7 +50,8 @@ class PatternView extends Component {
diff --git a/src/utils.js b/src/utils.js new file mode 100644 index 0000000..1099c58 --- /dev/null +++ b/src/utils.js @@ -0,0 +1,50 @@ +export const ws = new WebSocket('ws://localhost:1890/ws'); +const timeout = 250; +export const connect = () => { + let connectInterval; + + ws.onopen = () => { + console.log("connected websocket main component"); + + this.setState({ ws: ws }); + + clearTimeout(connectInterval); + }; + + // websocket onclose event listener + ws.onclose = e => { + console.log( + `Socket is closed. Reconnect will be attempted in ${Math.min( + 10000 / 1000, + (timeout + timeout) / 1000 + )} second.`, + e.reason + ); + + let retryTimeout = timeout + timeout; //increment retry interval + connectInterval = setTimeout(check, Math.min(10000, retryTimeout)); //call check function after timeout + }; + + // websocket onerror event listener + ws.onerror = err => { + console.error( + "Socket encountered error: ", + err.message, + "Closing socket" + ); + + ws.close(); + }; +}; + +export const check = () => { + if (!ws || ws.readyState === WebSocket.CLOSED) connect(); //check if websocket instance is closed, if so call `connect` function. +}; + +export const sendMessage = (data) => { + try { + ws.send(JSON.stringify(data)) //send data to the server + } catch (error) { + console.log(error) // catch error + } +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index c080ee3..5d7b398 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10975,6 +10975,11 @@ uuid@^3.0.1, uuid@^3.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== +uuid@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5" + integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg== + v8-compile-cache@^2.0.3: version "2.1.1" resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.1.1.tgz#54bc3cdd43317bca91e35dcaf305b1a7237de745"