Add support for custom streams list

Also, fix bug where unique stream ids were not being generated.
This commit is contained in:
Max Goodhart
2020-06-19 19:14:03 -07:00
parent e80b0075f6
commit 490b626a06
4 changed files with 144 additions and 46 deletions

View File

@@ -20,22 +20,30 @@ Mousetrap.bind('ctrl+shift+i', () => {
ipcRenderer.send('devtools-overlay') ipcRenderer.send('devtools-overlay')
}) })
function Overlay({ spaces, streamData }) { function Overlay({ views, streams, customStreams }) {
const activeSpaces = spaces.filter((s) => s.matches('displaying')) const activeViews = views
.map(({ state, context }) => State.from(state, context))
.filter((s) => s.matches('displaying'))
return ( return (
<div> <div>
{activeSpaces.map((spaceState) => { {activeViews.map((viewState) => {
const { url, pos } = spaceState.context const { url, pos } = viewState.context
const data = streamData.find((d) => url === d.Link) const data = [...streams, ...customStreams].find((d) => url === d.Link)
const isListening = spaceState.matches('displaying.running.listening') const isListening = viewState.matches('displaying.running.listening')
const isLoading = spaceState.matches('displaying.loading') const isLoading = viewState.matches('displaying.loading')
return ( return (
<SpaceBorder pos={pos} isListening={isListening}> <SpaceBorder pos={pos} isListening={isListening}>
{data && ( {data && (
<StreamTitle isListening={isListening}> <StreamTitle isListening={isListening}>
<StreamIcon url={url} /> <StreamIcon url={url} />
<span> <span>
{data.Source} &ndash; {data.City} {data.State} {data.hasOwnProperty('Label') ? (
data.Label
) : (
<>
{data.Source} &ndash; {data.City} {data.State}
</>
)}
</span> </span>
</StreamTitle> </StreamTitle>
)} )}
@@ -49,21 +57,22 @@ function Overlay({ spaces, streamData }) {
} }
function App() { function App() {
const [spaces, setSpaces] = useState([]) const [state, setState] = useState({
const [streamData, setStreamData] = useState([]) views: [],
streams: [],
customStreams: [],
})
useEffect(() => { useEffect(() => {
ipcRenderer.on('view-states', (ev, viewStates) => { ipcRenderer.on('state', (ev, state) => {
setSpaces( setState(state)
viewStates.map(({ state, context }) => State.from(state, context)),
)
})
ipcRenderer.on('stream-data', (ev, data) => {
setStreamData(data)
}) })
}, []) }, [])
return <Overlay spaces={spaces} streamData={streamData} /> const { views, streams, customStreams } = state
return (
<Overlay views={views} streams={streams} customStreams={customStreams} />
)
} }
function StreamIcon({ url, ...props }) { function StreamIcon({ url, ...props }) {

View File

@@ -46,28 +46,34 @@ export async function* pollSpreadsheetData(creds, sheetId, tabName) {
} }
} }
export async function* processData(dataGen) { export class StreamIDGenerator {
// Give each stream a unique and recognizable short id. constructor(parent) {
const idMap = new Map() this.idMap = new Map(parent ? parent.idMap : null)
for await (const data of dataGen) { this.idSet = new Set(this.idMap.values())
for (const stream of data) { }
const { Link, Source } = stream
process(streams) {
const { idMap, idSet } = this
for (const stream of streams) {
const { Link, Source, Label } = stream
if (!idMap.has(Link)) { if (!idMap.has(Link)) {
let counter = 0 let counter = 0
let newId let newId
const normalizedSource = Source.toLowerCase() const normalizedText = (Source || Label || Link)
.toLowerCase()
.replace(/[^\w]/g, '') .replace(/[^\w]/g, '')
.replace(/^the|^https?(www)?/, '') .replace(/^the|^https?(www)?/, '')
do { do {
const sourcePart = normalizedSource.substr(0, 3).toLowerCase() const textPart = normalizedText.substr(0, 3).toLowerCase()
const counterPart = counter === 0 ? '' : counter const counterPart = counter === 0 && textPart ? '' : counter
newId = `${sourcePart}${counterPart}` newId = `${textPart}${counterPart}`
counter++ counter++
} while (idMap.has(newId)) } while (idSet.has(newId))
idMap.set(Link, newId) idMap.set(Link, newId)
idSet.add(newId)
} }
stream._id = idMap.get(Link) stream._id = idMap.get(Link)
} }
yield data return streams
} }
} }

View File

@@ -2,7 +2,7 @@ import fs from 'fs'
import yargs from 'yargs' import yargs from 'yargs'
import { app, shell, BrowserWindow } from 'electron' import { app, shell, BrowserWindow } from 'electron'
import { pollPublicData, pollSpreadsheetData, processData } from './data' import { pollPublicData, pollSpreadsheetData, StreamIDGenerator } from './data'
import StreamWindow from './StreamWindow' import StreamWindow from './StreamWindow'
import initWebServer from './server' import initWebServer from './server'
@@ -49,12 +49,14 @@ async function main() {
}) })
.help().argv .help().argv
const idGen = new StreamIDGenerator()
const streamWindow = new StreamWindow() const streamWindow = new StreamWindow()
streamWindow.init() streamWindow.init()
let browseWindow = null let browseWindow = null
const clientState = {} const clientState = { streams: [], customStreams: [], views: [] }
const getInitialState = () => clientState const getInitialState = () => clientState
let broadcastState = () => {} let broadcastState = () => {}
const onMessage = (msg) => { const onMessage = (msg) => {
@@ -62,6 +64,11 @@ async function main() {
streamWindow.setViews(new Map(msg.views)) streamWindow.setViews(new Map(msg.views))
} else if (msg.type === 'set-listening-view') { } else if (msg.type === 'set-listening-view') {
streamWindow.setListeningView(msg.viewIdx) 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') { } else if (msg.type === 'reload-view') {
streamWindow.reloadView(msg.viewIdx) streamWindow.reloadView(msg.viewIdx)
} else if (msg.type === 'browse') { } else if (msg.type === 'browse') {
@@ -90,8 +97,8 @@ async function main() {
} }
streamWindow.on('state', (viewStates) => { streamWindow.on('state', (viewStates) => {
streamWindow.send('view-states', viewStates)
clientState.views = viewStates clientState.views = viewStates
streamWindow.send('state', clientState)
broadcastState(clientState) broadcastState(clientState)
}) })
@@ -102,9 +109,10 @@ async function main() {
dataGen = pollPublicData() dataGen = pollPublicData()
} }
for await (const streams of processData(dataGen)) { for await (const rawStreams of dataGen) {
streamWindow.send('stream-data', streams) const streams = idGen.process(rawStreams)
clientState.streams = streams clientState.streams = streams
streamWindow.send('state', clientState)
broadcastState(clientState) broadcastState(clientState)
} }
} }

View File

@@ -1,6 +1,6 @@
import range from 'lodash/range' import range from 'lodash/range'
import ReconnectingWebSocket from 'reconnecting-websocket' 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 { useEffect, useState, useCallback, useRef } from 'preact/hooks'
import { State } from 'xstate' import { State } from 'xstate'
import styled, { css } from 'styled-components' import styled, { css } from 'styled-components'
@@ -28,7 +28,8 @@ function emptyStateIdxMap() {
function App({ wsEndpoint }) { function App({ wsEndpoint }) {
const wsRef = useRef() const wsRef = useRef()
const [isConnected, setIsConnected] = useState(false) const [isConnected, setIsConnected] = useState(false)
const [streamData, setStreamData] = useState() const [streams, setStreams] = useState([])
const [customStreams, setCustomStreams] = useState([])
const [stateIdxMap, setStateIdxMap] = useState(emptyStateIdxMap()) const [stateIdxMap, setStateIdxMap] = useState(emptyStateIdxMap())
useEffect(() => { useEffect(() => {
@@ -42,14 +43,19 @@ function App({ wsEndpoint }) {
ws.addEventListener('message', (ev) => { ws.addEventListener('message', (ev) => {
const msg = JSON.parse(ev.data) const msg = JSON.parse(ev.data)
if (msg.type === 'state') { if (msg.type === 'state') {
const { streams, views } = msg.state const {
streams: newStreams,
views,
customStreams: newCustomStreams,
} = msg.state
const newStateIdxMap = emptyStateIdxMap() const newStateIdxMap = emptyStateIdxMap()
const allStreams = [...newStreams, ...newCustomStreams]
for (const viewState of views) { for (const viewState of views) {
const { pos, url } = viewState.context const { pos, url } = viewState.context
if (!url) { if (!url) {
continue 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 state = State.from(viewState.state)
const isListening = state.matches('displaying.running.listening') const isListening = state.matches('displaying.running.listening')
for (const space of pos.spaces) { for (const space of pos.spaces) {
@@ -62,7 +68,8 @@ function App({ wsEndpoint }) {
} }
} }
setStateIdxMap(newStateIdxMap) setStateIdxMap(newStateIdxMap)
setStreamData(streams) setStreams(newStreams)
setCustomStreams(newCustomStreams)
} else { } else {
console.warn('unexpected ws message', msg) console.warn('unexpected ws message', msg)
} }
@@ -73,7 +80,8 @@ function App({ wsEndpoint }) {
const handleSetView = useCallback( const handleSetView = useCallback(
(idx, streamId) => { (idx, streamId) => {
const newSpaceIdxMap = new Map(stateIdxMap) 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) { if (url) {
newSpaceIdxMap.set(idx, { newSpaceIdxMap.set(idx, {
...newSpaceIdxMap.get(idx), ...newSpaceIdxMap.get(idx),
@@ -93,7 +101,7 @@ function App({ wsEndpoint }) {
]) ])
wsRef.current.send(JSON.stringify({ type: 'set-views', views })) wsRef.current.send(JSON.stringify({ type: 'set-views', views }))
}, },
[streamData, stateIdxMap], [streams, customStreams, stateIdxMap],
) )
const handleSetListening = useCallback((idx, listening) => { 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 ( return (
<div> <div>
<h1>Stream Wall</h1> <h1>Stream Wall</h1>
@@ -157,21 +177,47 @@ function App({ wsEndpoint }) {
))} ))}
</div> </div>
<div> <div>
{streamData {isConnected
? streamData.map((row) => <StreamLine id={row._id} row={row} />) ? [...streams, ...customStreams.values()].map((row) => (
<StreamLine id={row._id} row={row} />
))
: 'loading...'} : 'loading...'}
</div> </div>
<h2>Custom Streams</h2>
<div>
{/*
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) => (
<CustomStreamInput
key={idx}
idx={idx}
Link={Link}
Label={Label}
onChange={handleChangeCustomStream}
/>
),
)}
</div>
</StyledDataContainer> </StyledDataContainer>
</div> </div>
) )
} }
function StreamLine({ id, row: { Source, Title, Link, Notes } }) { function StreamLine({ id, row: { Label, Source, Title, Link, Notes } }) {
return ( return (
<StyledStreamLine> <StyledStreamLine>
<StyledId>{id}</StyledId> <StyledId>{id}</StyledId>
<div> <div>
<strong>{Source}</strong> <a href={Link}>{Title || Link}</a> {Notes} {Label ? (
Label
) : (
<>
<strong>{Source}</strong> <a href={Link}>{Title || Link}</a> {Notes}
</>
)}
</div> </div>
</StyledStreamLine> </StyledStreamLine>
) )
@@ -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 (
<div>
<input
onChange={handleChangeLink}
placeholder="https://..."
value={props.Link}
/>
<input
onChange={handleChangeLabel}
placeholder="Label (optional)"
value={props.Label}
/>
</div>
)
}
function ListeningButton(props) { function ListeningButton(props) {
return ( return (
<StyledListeningButton {...props}> <StyledListeningButton {...props}>