mirror of
https://github.com/streamwall/streamwall.git
synced 2026-01-31 09:22:49 -05:00
Add support for custom streams list
Also, fix bug where unique stream ids were not being generated.
This commit is contained in:
@@ -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} – {data.City} {data.State}
|
{data.hasOwnProperty('Label') ? (
|
||||||
|
data.Label
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{data.Source} – {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 }) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
Reference in New Issue
Block a user