From 490b626a069359c2112744b5b050d2d88bb3dfa8 Mon Sep 17 00:00:00 2001 From: Max Goodhart Date: Fri, 19 Jun 2020 19:14:03 -0700 Subject: [PATCH] Add support for custom streams list Also, fix bug where unique stream ids were not being generated. --- src/browser/overlay.js | 45 ++++++++++++-------- src/node/data.js | 30 +++++++------ src/node/index.js | 18 +++++--- src/web/control.js | 97 +++++++++++++++++++++++++++++++++++++----- 4 files changed, 144 insertions(+), 46 deletions(-) diff --git a/src/browser/overlay.js b/src/browser/overlay.js index fd133fd..10cfa52 100644 --- a/src/browser/overlay.js +++ b/src/browser/overlay.js @@ -20,22 +20,30 @@ Mousetrap.bind('ctrl+shift+i', () => { ipcRenderer.send('devtools-overlay') }) -function Overlay({ spaces, streamData }) { - const activeSpaces = spaces.filter((s) => s.matches('displaying')) +function Overlay({ views, streams, customStreams }) { + const activeViews = views + .map(({ state, context }) => State.from(state, context)) + .filter((s) => s.matches('displaying')) return (
- {activeSpaces.map((spaceState) => { - const { url, pos } = spaceState.context - const data = streamData.find((d) => url === d.Link) - const isListening = spaceState.matches('displaying.running.listening') - const isLoading = spaceState.matches('displaying.loading') + {activeViews.map((viewState) => { + const { url, pos } = viewState.context + const data = [...streams, ...customStreams].find((d) => url === d.Link) + const isListening = viewState.matches('displaying.running.listening') + const isLoading = viewState.matches('displaying.loading') return ( {data && ( - {data.Source} – {data.City} {data.State} + {data.hasOwnProperty('Label') ? ( + data.Label + ) : ( + <> + {data.Source} – {data.City} {data.State} + + )} )} @@ -49,21 +57,22 @@ function Overlay({ spaces, streamData }) { } function App() { - const [spaces, setSpaces] = useState([]) - const [streamData, setStreamData] = useState([]) + const [state, setState] = useState({ + views: [], + streams: [], + customStreams: [], + }) useEffect(() => { - ipcRenderer.on('view-states', (ev, viewStates) => { - setSpaces( - viewStates.map(({ state, context }) => State.from(state, context)), - ) - }) - ipcRenderer.on('stream-data', (ev, data) => { - setStreamData(data) + ipcRenderer.on('state', (ev, state) => { + setState(state) }) }, []) - return + const { views, streams, customStreams } = state + return ( + + ) } function StreamIcon({ url, ...props }) { diff --git a/src/node/data.js b/src/node/data.js index 80a106f..212e592 100644 --- a/src/node/data.js +++ b/src/node/data.js @@ -46,28 +46,34 @@ export async function* pollSpreadsheetData(creds, sheetId, tabName) { } } -export async function* processData(dataGen) { - // Give each stream a unique and recognizable short id. - const idMap = new Map() - for await (const data of dataGen) { - for (const stream of data) { - const { Link, Source } = stream +export class StreamIDGenerator { + constructor(parent) { + this.idMap = new Map(parent ? parent.idMap : null) + this.idSet = new Set(this.idMap.values()) + } + + process(streams) { + const { idMap, idSet } = this + for (const stream of streams) { + const { Link, Source, Label } = stream if (!idMap.has(Link)) { let counter = 0 let newId - const normalizedSource = Source.toLowerCase() + const normalizedText = (Source || Label || Link) + .toLowerCase() .replace(/[^\w]/g, '') .replace(/^the|^https?(www)?/, '') do { - const sourcePart = normalizedSource.substr(0, 3).toLowerCase() - const counterPart = counter === 0 ? '' : counter - newId = `${sourcePart}${counterPart}` + const textPart = normalizedText.substr(0, 3).toLowerCase() + const counterPart = counter === 0 && textPart ? '' : counter + newId = `${textPart}${counterPart}` counter++ - } while (idMap.has(newId)) + } while (idSet.has(newId)) idMap.set(Link, newId) + idSet.add(newId) } stream._id = idMap.get(Link) } - yield data + return streams } } diff --git a/src/node/index.js b/src/node/index.js index 885ee76..cf875ea 100644 --- a/src/node/index.js +++ b/src/node/index.js @@ -2,7 +2,7 @@ import fs from 'fs' import yargs from 'yargs' import { app, shell, BrowserWindow } from 'electron' -import { pollPublicData, pollSpreadsheetData, processData } from './data' +import { pollPublicData, pollSpreadsheetData, StreamIDGenerator } from './data' import StreamWindow from './StreamWindow' import initWebServer from './server' @@ -49,12 +49,14 @@ async function main() { }) .help().argv + const idGen = new StreamIDGenerator() + const streamWindow = new StreamWindow() streamWindow.init() let browseWindow = null - const clientState = {} + const clientState = { streams: [], customStreams: [], views: [] } const getInitialState = () => clientState let broadcastState = () => {} const onMessage = (msg) => { @@ -62,6 +64,11 @@ async function main() { streamWindow.setViews(new Map(msg.views)) } else if (msg.type === 'set-listening-view') { streamWindow.setListeningView(msg.viewIdx) + } else if (msg.type === 'set-custom-streams') { + const customIDGen = new StreamIDGenerator(idGen) + clientState.customStreams = customIDGen.process(msg.streams) + streamWindow.send('state', clientState) + broadcastState(clientState) } else if (msg.type === 'reload-view') { streamWindow.reloadView(msg.viewIdx) } else if (msg.type === 'browse') { @@ -90,8 +97,8 @@ async function main() { } streamWindow.on('state', (viewStates) => { - streamWindow.send('view-states', viewStates) clientState.views = viewStates + streamWindow.send('state', clientState) broadcastState(clientState) }) @@ -102,9 +109,10 @@ async function main() { dataGen = pollPublicData() } - for await (const streams of processData(dataGen)) { - streamWindow.send('stream-data', streams) + for await (const rawStreams of dataGen) { + const streams = idGen.process(rawStreams) clientState.streams = streams + streamWindow.send('state', clientState) broadcastState(clientState) } } diff --git a/src/web/control.js b/src/web/control.js index 4e545ee..c0e79b9 100644 --- a/src/web/control.js +++ b/src/web/control.js @@ -1,6 +1,6 @@ import range from 'lodash/range' import ReconnectingWebSocket from 'reconnecting-websocket' -import { h, render } from 'preact' +import { h, Fragment, render } from 'preact' import { useEffect, useState, useCallback, useRef } from 'preact/hooks' import { State } from 'xstate' import styled, { css } from 'styled-components' @@ -28,7 +28,8 @@ function emptyStateIdxMap() { function App({ wsEndpoint }) { const wsRef = useRef() const [isConnected, setIsConnected] = useState(false) - const [streamData, setStreamData] = useState() + const [streams, setStreams] = useState([]) + const [customStreams, setCustomStreams] = useState([]) const [stateIdxMap, setStateIdxMap] = useState(emptyStateIdxMap()) useEffect(() => { @@ -42,14 +43,19 @@ function App({ wsEndpoint }) { ws.addEventListener('message', (ev) => { const msg = JSON.parse(ev.data) if (msg.type === 'state') { - const { streams, views } = msg.state + const { + streams: newStreams, + views, + customStreams: newCustomStreams, + } = msg.state const newStateIdxMap = emptyStateIdxMap() + const allStreams = [...newStreams, ...newCustomStreams] for (const viewState of views) { const { pos, url } = viewState.context if (!url) { continue } - const streamId = streams.find((d) => d.Link === url)?._id + const streamId = allStreams.find((d) => d.Link === url)?._id const state = State.from(viewState.state) const isListening = state.matches('displaying.running.listening') for (const space of pos.spaces) { @@ -62,7 +68,8 @@ function App({ wsEndpoint }) { } } setStateIdxMap(newStateIdxMap) - setStreamData(streams) + setStreams(newStreams) + setCustomStreams(newCustomStreams) } else { console.warn('unexpected ws message', msg) } @@ -73,7 +80,8 @@ function App({ wsEndpoint }) { const handleSetView = useCallback( (idx, streamId) => { const newSpaceIdxMap = new Map(stateIdxMap) - const url = streamData.find((d) => d._id === streamId)?.Link + const url = [...streams, ...customStreams].find((d) => d._id === streamId) + ?.Link if (url) { newSpaceIdxMap.set(idx, { ...newSpaceIdxMap.get(idx), @@ -93,7 +101,7 @@ function App({ wsEndpoint }) { ]) wsRef.current.send(JSON.stringify({ type: 'set-views', views })) }, - [streamData, stateIdxMap], + [streams, customStreams, stateIdxMap], ) const handleSetListening = useCallback((idx, listening) => { @@ -123,6 +131,18 @@ function App({ wsEndpoint }) { ) }, []) + const handleChangeCustomStream = useCallback((idx, customStream) => { + let newCustomStreams = [...customStreams] + newCustomStreams[idx] = customStream + newCustomStreams = newCustomStreams.filter((s) => s.Link) + wsRef.current.send( + JSON.stringify({ + type: 'set-custom-streams', + streams: newCustomStreams, + }), + ) + }) + return (

Stream Wall

@@ -157,21 +177,47 @@ function App({ wsEndpoint }) { ))}
- {streamData - ? streamData.map((row) => ) + {isConnected + ? [...streams, ...customStreams.values()].map((row) => ( + + )) : 'loading...'}
+

Custom Streams

+
+ {/* + Include an empty object at the end to create an extra input for a new custom stream. + We need it to be part of the array (rather than JSX below) for DOM diffing to match the key and retain focus. + */} + {[...customStreams, { Link: '', Label: '' }].map( + ({ Link, Label }, idx) => ( + + ), + )} +
) } -function StreamLine({ id, row: { Source, Title, Link, Notes } }) { +function StreamLine({ id, row: { Label, Source, Title, Link, Notes } }) { return ( {id}
- {Source} {Title || Link} {Notes} + {Label ? ( + Label + ) : ( + <> + {Source} {Title || Link} {Notes} + + )}
) @@ -250,6 +296,35 @@ function GridInput({ ) } +function CustomStreamInput({ idx, onChange, ...props }) { + const handleChangeLink = useCallback( + (ev) => { + onChange(idx, { ...props, Link: ev.target.value }) + }, + [onChange], + ) + const handleChangeLabel = useCallback( + (ev) => { + onChange(idx, { ...props, Label: ev.target.value }) + }, + [onChange], + ) + return ( +
+ + +
+ ) +} + function ListeningButton(props) { return (