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 (