mirror of
https://github.com/streamwall/streamwall.git
synced 2026-01-24 22:22:50 -05:00
Initial release
This commit is contained in:
3
src/browser/.babelrc.json
Normal file
3
src/browser/.babelrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"presets": ["@babel/preset-env"]
|
||||
}
|
||||
14
src/browser/control.html
Normal file
14
src/browser/control.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Woke Streams</title>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; style-src 'self' 'unsafe-inline'"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<script src="control.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
209
src/browser/control.js
Normal file
209
src/browser/control.js
Normal file
@@ -0,0 +1,209 @@
|
||||
import { ipcRenderer } from 'electron'
|
||||
import range from 'lodash/range'
|
||||
import { h, render } from 'preact'
|
||||
import { useEffect, useState, useCallback } from 'preact/hooks'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import './index.css'
|
||||
import SoundIcon from './static/volume-up-solid.svg'
|
||||
|
||||
function App() {
|
||||
const [streamData, setStreamData] = useState()
|
||||
const [spaceIdxMap, setSpaceIdxMap] = useState(new Map())
|
||||
const [listeningIdx, setListeningIdx] = useState()
|
||||
|
||||
useEffect(() => {
|
||||
ipcRenderer.on('stream-data', (ev, data) => {
|
||||
setStreamData(data)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSetSpace = useCallback(
|
||||
(idx, value) => {
|
||||
const newSpaceIdxMap = new Map(spaceIdxMap)
|
||||
if (value !== undefined) {
|
||||
newSpaceIdxMap.set(idx, value)
|
||||
} else {
|
||||
newSpaceIdxMap.delete(idx)
|
||||
}
|
||||
setSpaceIdxMap(newSpaceIdxMap)
|
||||
|
||||
const newSpaceURLMap = new Map(
|
||||
Array.from(newSpaceIdxMap, ([spaceIdx, dataIdx]) => [
|
||||
spaceIdx,
|
||||
streamData[dataIdx].Link,
|
||||
]),
|
||||
)
|
||||
ipcRenderer.send('set-videos', newSpaceURLMap)
|
||||
},
|
||||
[streamData, spaceIdxMap],
|
||||
)
|
||||
|
||||
const handleSetListening = useCallback(
|
||||
(idx) => {
|
||||
const newIdx = idx === listeningIdx ? null : idx
|
||||
setListeningIdx(newIdx)
|
||||
ipcRenderer.send('set-sound-source', newIdx)
|
||||
},
|
||||
[listeningIdx],
|
||||
)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Stream Wall</h1>
|
||||
<div>
|
||||
{range(0, 3).map((y) => (
|
||||
<StyledGridLine>
|
||||
{range(0, 3).map((x) => {
|
||||
const idx = 3 * y + x
|
||||
return (
|
||||
<GridInput
|
||||
idx={idx}
|
||||
onChangeSpace={handleSetSpace}
|
||||
spaceValue={spaceIdxMap.get(idx)}
|
||||
isListening={idx === listeningIdx}
|
||||
onSetListening={handleSetListening}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</StyledGridLine>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
{streamData
|
||||
? streamData.map((row, idx) => <StreamLine idx={idx} row={row} />)
|
||||
: 'loading...'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StreamLine({ idx, row: { Source, Title, Link, Notes } }) {
|
||||
return (
|
||||
<StyledStreamLine>
|
||||
<StyledIdx>{idx}</StyledIdx>
|
||||
<div>
|
||||
<strong>{Source}</strong> <a href={Link}>{Title || Link}</a> {Notes}
|
||||
</div>
|
||||
</StyledStreamLine>
|
||||
)
|
||||
}
|
||||
|
||||
function GridInput({
|
||||
idx,
|
||||
onChangeSpace,
|
||||
spaceValue,
|
||||
isListening,
|
||||
onSetListening,
|
||||
}) {
|
||||
const handleChange = useCallback(
|
||||
(ev) => {
|
||||
const { name, value } = ev.target
|
||||
const newValue = value ? Number(value) : NaN
|
||||
onChangeSpace(
|
||||
Number(name),
|
||||
Number.isFinite(newValue) ? newValue : undefined,
|
||||
)
|
||||
},
|
||||
[onChangeSpace],
|
||||
)
|
||||
const handleListeningClick = useCallback(() => onSetListening(idx), [
|
||||
idx,
|
||||
onSetListening,
|
||||
])
|
||||
const handleClick = useCallback((ev) => {
|
||||
ev.target.select()
|
||||
})
|
||||
return (
|
||||
<StyledGridContainer>
|
||||
<ListeningButton
|
||||
isListening={isListening}
|
||||
onClick={handleListeningClick}
|
||||
/>
|
||||
<StyledGridInput
|
||||
name={idx}
|
||||
value={spaceValue}
|
||||
onClick={handleClick}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</StyledGridContainer>
|
||||
)
|
||||
}
|
||||
|
||||
function ListeningButton(props) {
|
||||
return (
|
||||
<StyledListeningButton {...props}>
|
||||
<SoundIcon />
|
||||
</StyledListeningButton>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledGridLine = styled.div`
|
||||
display: flex;
|
||||
`
|
||||
|
||||
const StyledListeningButton = styled.button`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 2px solid gray;
|
||||
border-color: ${({ isListening }) => (isListening ? 'red' : 'gray')};
|
||||
background: ${({ isListening }) => (isListening ? '#c77' : '#ccc')};
|
||||
border-radius: 5px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 10px orange inset;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
`
|
||||
|
||||
const StyledGridContainer = styled.div`
|
||||
position: relative;
|
||||
|
||||
${StyledListeningButton} {
|
||||
position: absolute;
|
||||
bottom: 5px;
|
||||
right: 5px;
|
||||
}
|
||||
`
|
||||
|
||||
const StyledGridInput = styled.input`
|
||||
width: 150px;
|
||||
height: 50px;
|
||||
padding: 20px;
|
||||
border: 2px solid black;
|
||||
font-size: 20px;
|
||||
text-align: center;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
box-shadow: 0 0 5px orange inset;
|
||||
}
|
||||
`
|
||||
|
||||
const StyledIdx = styled.div`
|
||||
flex-shrink: 0;
|
||||
margin-right: 5px;
|
||||
background: #333;
|
||||
color: white;
|
||||
padding: 3px;
|
||||
border-radius: 5px;
|
||||
width: 2em;
|
||||
text-align: center;
|
||||
`
|
||||
|
||||
const StyledStreamLine = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0.5em 0;
|
||||
`
|
||||
|
||||
function main() {
|
||||
render(<App />, document.body)
|
||||
}
|
||||
|
||||
main()
|
||||
15
src/browser/index.css
Normal file
15
src/browser/index.css
Normal file
@@ -0,0 +1,15 @@
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
font-weight: normal;
|
||||
src: url(./static/NotoSans-Regular.ttf) format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Noto Sans';
|
||||
font-weight: 600;
|
||||
src: url(./static/NotoSans-SemiBold.ttf) format('truetype');
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans';
|
||||
}
|
||||
14
src/browser/overlay.html
Normal file
14
src/browser/overlay.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Woke Stream Overlay</title>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; style-src 'self' 'unsafe-inline'"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<script src="overlay.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
167
src/browser/overlay.js
Normal file
167
src/browser/overlay.js
Normal file
@@ -0,0 +1,167 @@
|
||||
import { ipcRenderer } from 'electron'
|
||||
import { h, Fragment, render } from 'preact'
|
||||
import { useEffect, useState } from 'preact/hooks'
|
||||
import { State } from 'xstate'
|
||||
import styled from 'styled-components'
|
||||
import Mousetrap from 'mousetrap'
|
||||
import { TailSpin } from 'svg-loaders-react'
|
||||
|
||||
import './index.css'
|
||||
import { WIDTH, HEIGHT } from '../constants'
|
||||
|
||||
import InstagramIcon from './static/instagram.svg'
|
||||
import FacebookIcon from './static/facebook.svg'
|
||||
import PeriscopeIcon from './static/periscope.svg'
|
||||
import TwitchIcon from './static/twitch.svg'
|
||||
import YouTubeIcon from './static/youtube.svg'
|
||||
import SoundIcon from './static/volume-up-solid.svg'
|
||||
|
||||
Mousetrap.bind('ctrl+shift+i', () => {
|
||||
ipcRenderer.send('devtools-overlay')
|
||||
})
|
||||
|
||||
function Overlay({ spaces, streamData }) {
|
||||
const activeSpaces = spaces.filter((s) => s.matches('displaying'))
|
||||
return (
|
||||
<div>
|
||||
{activeSpaces.map((spaceState) => {
|
||||
const { url, bounds } = spaceState.context
|
||||
const data = streamData.find((d) => url === d.Link)
|
||||
const isListening = spaceState.matches('displaying.running.listening')
|
||||
const isLoading = spaceState.matches('displaying.loading')
|
||||
return (
|
||||
<SpaceBorder bounds={bounds} isListening={isListening}>
|
||||
{data && (
|
||||
<>
|
||||
<StreamTitle>
|
||||
<StreamIcon url={url} />
|
||||
{data.Source} – {data.City} {data.State}
|
||||
</StreamTitle>
|
||||
</>
|
||||
)}
|
||||
{isLoading && <LoadingSpinner />}
|
||||
{isListening && <ListeningIndicator />}
|
||||
</SpaceBorder>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [spaces, setSpaces] = useState([])
|
||||
const [streamData, setStreamData] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
const spaceStateMap = new Map()
|
||||
ipcRenderer.on('space-state', (ev, idx, { state, context }) => {
|
||||
spaceStateMap.set(idx, State.from(state, context))
|
||||
setSpaces([...spaceStateMap.values()])
|
||||
})
|
||||
ipcRenderer.on('stream-data', (ev, data) => {
|
||||
setStreamData(data)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return <Overlay spaces={spaces} streamData={streamData} />
|
||||
}
|
||||
|
||||
function StreamIcon({ url, ...props }) {
|
||||
let parsedURL
|
||||
try {
|
||||
parsedURL = new URL(url)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
let { host } = parsedURL
|
||||
host = host.replace(/^www\./, '')
|
||||
if (host === 'youtube.com' || host === 'youtu.be') {
|
||||
return <YouTubeIcon {...props} />
|
||||
} else if (host === 'facebook.com' || host === 'm.facebook.com') {
|
||||
return <FacebookIcon {...props} />
|
||||
} else if (host === 'twitch.tv') {
|
||||
return <TwitchIcon {...props} />
|
||||
} else if (host === 'periscope.tv' || host === 'pscp.tv') {
|
||||
return <PeriscopeIcon {...props} />
|
||||
} else if (host === 'instagram.com') {
|
||||
return <InstagramIcon {...props} />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const SpaceBorder = styled.div.attrs((props) => ({
|
||||
borderWidth: 2,
|
||||
}))`
|
||||
position: fixed;
|
||||
left: ${({ bounds }) => bounds.x}px;
|
||||
top: ${({ bounds }) => bounds.y}px;
|
||||
width: ${({ bounds }) => bounds.width}px;
|
||||
height: ${({ bounds }) => bounds.height}px;
|
||||
border: 0 solid black;
|
||||
border-left-width: ${({ bounds, borderWidth }) =>
|
||||
bounds.x === 0 ? 0 : borderWidth}px;
|
||||
border-right-width: ${({ bounds, borderWidth }) =>
|
||||
bounds.x + bounds.width === WIDTH ? 0 : borderWidth}px;
|
||||
border-top-width: ${({ bounds, borderWidth }) =>
|
||||
bounds.y === 0 ? 0 : borderWidth}px;
|
||||
border-bottom-width: ${({ bounds, borderWidth }) =>
|
||||
bounds.y + bounds.height === HEIGHT ? 0 : borderWidth}px;
|
||||
box-shadow: ${({ isListening }) =>
|
||||
isListening ? '0 0 10px red inset' : 'none'};
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
`
|
||||
|
||||
const StreamTitle = styled.div`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 10px;
|
||||
margin: 5px;
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
color: white;
|
||||
text-shadow: 0 0 4px black;
|
||||
letter-spacing: -0.025em;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 4px;
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
svg {
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
margin-right: 0.35em;
|
||||
overflow: visible;
|
||||
filter: drop-shadow(0 0 4px black);
|
||||
|
||||
path {
|
||||
fill: white;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const LoadingSpinner = styled(TailSpin)`
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
opacity: 0.5;
|
||||
`
|
||||
|
||||
const ListeningIndicator = styled(SoundIcon)`
|
||||
position: absolute;
|
||||
right: 15px;
|
||||
bottom: 10px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
opacity: 0.9;
|
||||
|
||||
path {
|
||||
fill: red;
|
||||
}
|
||||
`
|
||||
|
||||
render(<App />, document.body)
|
||||
BIN
src/browser/static/NotoSans-Regular.ttf
Normal file
BIN
src/browser/static/NotoSans-Regular.ttf
Normal file
Binary file not shown.
BIN
src/browser/static/NotoSans-SemiBold.ttf
Normal file
BIN
src/browser/static/NotoSans-SemiBold.ttf
Normal file
Binary file not shown.
1
src/browser/static/facebook.svg
Normal file
1
src/browser/static/facebook.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M504 256C504 119 393 8 256 8S8 119 8 256c0 123.78 90.69 226.38 209.25 245V327.69h-63V256h63v-54.64c0-62.15 37-96.48 93.67-96.48 27.14 0 55.52 4.84 55.52 4.84v61h-31.28c-30.8 0-40.41 19.12-40.41 38.73V256h68.78l-11 71.69h-57.78V501C413.31 482.38 504 379.78 504 256z"/></svg>
|
||||
|
After Width: | Height: | Size: 344 B |
1
src/browser/static/instagram.svg
Normal file
1
src/browser/static/instagram.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M224.1 141c-63.6 0-114.9 51.3-114.9 114.9s51.3 114.9 114.9 114.9S339 319.5 339 255.9 287.7 141 224.1 141zm0 189.6c-41.1 0-74.7-33.5-74.7-74.7s33.5-74.7 74.7-74.7 74.7 33.5 74.7 74.7-33.6 74.7-74.7 74.7zm146.4-194.3c0 14.9-12 26.8-26.8 26.8-14.9 0-26.8-12-26.8-26.8s12-26.8 26.8-26.8 26.8 12 26.8 26.8zm76.1 27.2c-1.7-35.9-9.9-67.7-36.2-93.9-26.2-26.2-58-34.4-93.9-36.2-37-2.1-147.9-2.1-184.9 0-35.8 1.7-67.6 9.9-93.9 36.1s-34.4 58-36.2 93.9c-2.1 37-2.1 147.9 0 184.9 1.7 35.9 9.9 67.7 36.2 93.9s58 34.4 93.9 36.2c37 2.1 147.9 2.1 184.9 0 35.9-1.7 67.7-9.9 93.9-36.2 26.2-26.2 34.4-58 36.2-93.9 2.1-37 2.1-147.8 0-184.8zM398.8 388c-7.8 19.6-22.9 34.7-42.6 42.6-29.5 11.7-99.5 9-132.1 9s-102.7 2.6-132.1-9c-19.6-7.8-34.7-22.9-42.6-42.6-11.7-29.5-9-99.5-9-132.1s-2.6-102.7 9-132.1c7.8-19.6 22.9-34.7 42.6-42.6 29.5-11.7 99.5-9 132.1-9s102.7-2.6 132.1 9c19.6 7.8 34.7 22.9 42.6 42.6 11.7 29.5 9 99.5 9 132.1s2.7 102.7-9 132.1z"/></svg>
|
||||
|
After Width: | Height: | Size: 1002 B |
1
src/browser/static/periscope.svg
Normal file
1
src/browser/static/periscope.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M370 63.6C331.4 22.6 280.5 0 226.6 0 111.9 0 18.5 96.2 18.5 214.4c0 75.1 57.8 159.8 82.7 192.7C137.8 455.5 192.6 512 226.6 512c41.6 0 112.9-94.2 120.9-105 24.6-33.1 82-118.3 82-192.6 0-56.5-21.1-110.1-59.5-150.8zM226.6 493.9c-42.5 0-190-167.3-190-279.4 0-107.4 83.9-196.3 190-196.3 100.8 0 184.7 89 184.7 196.3.1 112.1-147.4 279.4-184.7 279.4zM338 206.8c0 59.1-51.1 109.7-110.8 109.7-100.6 0-150.7-108.2-92.9-181.8v.4c0 24.5 20.1 44.4 44.8 44.4 24.7 0 44.8-19.9 44.8-44.4 0-18.2-11.1-33.8-26.9-40.7 76.6-19.2 141 39.3 141 112.4z"/></svg>
|
||||
|
After Width: | Height: | Size: 608 B |
1
src/browser/static/twitch.svg
Normal file
1
src/browser/static/twitch.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M391.17,103.47H352.54v109.7h38.63ZM285,103H246.37V212.75H285ZM120.83,0,24.31,91.42V420.58H140.14V512l96.53-91.42h77.25L487.69,256V0ZM449.07,237.75l-77.22,73.12H294.61l-67.6,64v-64H140.14V36.58H449.07Z"/></svg>
|
||||
|
After Width: | Height: | Size: 281 B |
1
src/browser/static/volume-up-solid.svg
Normal file
1
src/browser/static/volume-up-solid.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="volume-up" class="svg-inline--fa fa-volume-up fa-w-18" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path fill="currentColor" d="M215.03 71.05L126.06 160H24c-13.26 0-24 10.74-24 24v144c0 13.25 10.74 24 24 24h102.06l88.97 88.95c15.03 15.03 40.97 4.47 40.97-16.97V88.02c0-21.46-25.96-31.98-40.97-16.97zm233.32-51.08c-11.17-7.33-26.18-4.24-33.51 6.95-7.34 11.17-4.22 26.18 6.95 33.51 66.27 43.49 105.82 116.6 105.82 195.58 0 78.98-39.55 152.09-105.82 195.58-11.17 7.32-14.29 22.34-6.95 33.5 7.04 10.71 21.93 14.56 33.51 6.95C528.27 439.58 576 351.33 576 256S528.27 72.43 448.35 19.97zM480 256c0-63.53-32.06-121.94-85.77-156.24-11.19-7.14-26.03-3.82-33.12 7.46s-3.78 26.21 7.41 33.36C408.27 165.97 432 209.11 432 256s-23.73 90.03-63.48 115.42c-11.19 7.14-14.5 22.07-7.41 33.36 6.51 10.36 21.12 15.14 33.12 7.46C447.94 377.94 480 319.54 480 256zm-141.77-76.87c-11.58-6.33-26.19-2.16-32.61 9.45-6.39 11.61-2.16 26.2 9.45 32.61C327.98 228.28 336 241.63 336 256c0 14.38-8.02 27.72-20.92 34.81-11.61 6.41-15.84 21-9.45 32.61 6.43 11.66 21.05 15.8 32.61 9.45 28.23-15.55 45.77-45 45.77-76.88s-17.54-61.32-45.78-76.86z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
1
src/browser/static/youtube.svg
Normal file
1
src/browser/static/youtube.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M549.655 124.083c-6.281-23.65-24.787-42.276-48.284-48.597C458.781 64 288 64 288 64S117.22 64 74.629 75.486c-23.497 6.322-42.003 24.947-48.284 48.597-11.412 42.867-11.412 132.305-11.412 132.305s0 89.438 11.412 132.305c6.281 23.65 24.787 41.5 48.284 47.821C117.22 448 288 448 288 448s170.78 0 213.371-11.486c23.497-6.321 42.003-24.171 48.284-47.821 11.412-42.867 11.412-132.305 11.412-132.305s0-89.438-11.412-132.305zm-317.51 213.508V175.185l142.739 81.205-142.739 81.201z"/></svg>
|
||||
|
After Width: | Height: | Size: 550 B |
7
src/constants.js
Normal file
7
src/constants.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export const WIDTH = 1920
|
||||
export const HEIGHT = 1080
|
||||
export const GRID_COUNT = 3
|
||||
export const SPACE_WIDTH = Math.floor(WIDTH / GRID_COUNT)
|
||||
export const SPACE_HEIGHT = Math.floor(HEIGHT / GRID_COUNT)
|
||||
export const DATA_URL = 'https://woke.net/csv'
|
||||
export const REFRESH_INTERVAL = 5 * 60 * 1000
|
||||
13
src/node/.babelrc.json
Normal file
13
src/node/.babelrc.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"modules": "commonjs",
|
||||
"targets": {
|
||||
"node": true
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
54
src/node/geometry.js
Normal file
54
src/node/geometry.js
Normal file
@@ -0,0 +1,54 @@
|
||||
export function boxesFromSpaceURLMap(width, height, stateURLMap) {
|
||||
const boxes = []
|
||||
const visited = new Set()
|
||||
|
||||
function findLargestBox(x, y) {
|
||||
const idx = width * y + x
|
||||
const spaces = [idx]
|
||||
const url = stateURLMap.get(idx)
|
||||
|
||||
let maxY
|
||||
for (maxY = y + 1; maxY < height; maxY++) {
|
||||
const checkIdx = width * maxY + x
|
||||
if (visited.has(checkIdx) || stateURLMap.get(checkIdx) !== url) {
|
||||
break
|
||||
}
|
||||
spaces.push(width * maxY + x)
|
||||
}
|
||||
|
||||
let cx = x
|
||||
let cy = y
|
||||
scan: for (cx = x + 1; cx < width; cx++) {
|
||||
for (cy = y; cy < maxY; cy++) {
|
||||
const checkIdx = width * cy + cx
|
||||
if (visited.has(checkIdx) || stateURLMap.get(checkIdx) !== url) {
|
||||
break scan
|
||||
}
|
||||
}
|
||||
for (let cy = y; cy < maxY; cy++) {
|
||||
spaces.push(width * cy + cx)
|
||||
}
|
||||
}
|
||||
const w = cx - x
|
||||
const h = maxY - y
|
||||
spaces.sort()
|
||||
return { url, x, y, w, h, spaces }
|
||||
}
|
||||
|
||||
for (let y = 0; y < width; y++) {
|
||||
for (let x = 0; x < height; x++) {
|
||||
const idx = width * y + x
|
||||
if (visited.has(idx) || stateURLMap.get(idx) === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
const box = findLargestBox(x, y)
|
||||
boxes.push(box)
|
||||
for (const boxIdx of box.spaces) {
|
||||
visited.add(boxIdx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return boxes
|
||||
}
|
||||
91
src/node/geometry.test.js
Normal file
91
src/node/geometry.test.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { boxesFromSpaceURLMap } from './geometry'
|
||||
|
||||
const box1 = `
|
||||
ab
|
||||
ab
|
||||
`
|
||||
.replace(/\s/g, '')
|
||||
.split('')
|
||||
|
||||
const box2 = `
|
||||
aa
|
||||
bb
|
||||
`
|
||||
.replace(/\s/g, '')
|
||||
.split('')
|
||||
|
||||
const box3 = `
|
||||
aac
|
||||
aaa
|
||||
dae
|
||||
`
|
||||
.replace(/\s/g, '')
|
||||
.split('')
|
||||
|
||||
const box4 = `
|
||||
...
|
||||
.aa
|
||||
.aa
|
||||
`
|
||||
.replace(/\s/g, '')
|
||||
.split('')
|
||||
.map((c) => (c === '.' ? undefined : c))
|
||||
|
||||
const box5 = `
|
||||
..a
|
||||
..a
|
||||
.aa
|
||||
`
|
||||
.replace(/\s/g, '')
|
||||
.split('')
|
||||
.map((c) => (c === '.' ? undefined : c))
|
||||
|
||||
describe.each([
|
||||
[
|
||||
2,
|
||||
2,
|
||||
box1,
|
||||
[
|
||||
{ url: 'a', x: 0, y: 0, w: 1, h: 2, spaces: [0, 2] },
|
||||
{ url: 'b', x: 1, y: 0, w: 1, h: 2, spaces: [1, 3] },
|
||||
],
|
||||
],
|
||||
[
|
||||
2,
|
||||
2,
|
||||
box2,
|
||||
[
|
||||
{ url: 'a', x: 0, y: 0, w: 2, h: 1, spaces: [0, 1] },
|
||||
{ url: 'b', x: 0, y: 1, w: 2, h: 1, spaces: [2, 3] },
|
||||
],
|
||||
],
|
||||
[
|
||||
3,
|
||||
3,
|
||||
box3,
|
||||
[
|
||||
{ url: 'a', x: 0, y: 0, w: 2, h: 2, spaces: [0, 1, 3, 4] },
|
||||
{ url: 'c', x: 2, y: 0, w: 1, h: 1, spaces: [2] },
|
||||
{ url: 'a', x: 2, y: 1, w: 1, h: 1, spaces: [5] },
|
||||
{ url: 'd', x: 0, y: 2, w: 1, h: 1, spaces: [6] },
|
||||
{ url: 'a', x: 1, y: 2, w: 1, h: 1, spaces: [7] },
|
||||
{ url: 'e', x: 2, y: 2, w: 1, h: 1, spaces: [8] },
|
||||
],
|
||||
],
|
||||
[3, 3, box4, [{ url: 'a', x: 1, y: 1, w: 2, h: 2, spaces: [4, 5, 7, 8] }]],
|
||||
[
|
||||
3,
|
||||
3,
|
||||
box5,
|
||||
[
|
||||
{ url: 'a', x: 2, y: 0, w: 1, h: 3, spaces: [2, 5, 8] },
|
||||
{ url: 'a', x: 1, y: 2, w: 1, h: 1, spaces: [7] },
|
||||
],
|
||||
],
|
||||
])('boxesFromSpaceURLMap(%i, %i, %j)', (width, height, data, expected) => {
|
||||
test(`returns expected ${expected.length} boxes`, () => {
|
||||
const stateURLMap = new Map(data.map((v, idx) => [idx, v]))
|
||||
const result = boxesFromSpaceURLMap(width, height, stateURLMap)
|
||||
expect(result).toStrictEqual(expected)
|
||||
})
|
||||
})
|
||||
176
src/node/index.js
Normal file
176
src/node/index.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import { app, BrowserWindow, BrowserView, ipcMain, shell } from 'electron'
|
||||
import { interpret } from 'xstate'
|
||||
import fetch from 'node-fetch'
|
||||
import csv from 'csvtojson'
|
||||
|
||||
import viewStateMachine from './viewStateMachine'
|
||||
import { boxesFromSpaceURLMap } from './geometry'
|
||||
|
||||
import {
|
||||
WIDTH,
|
||||
HEIGHT,
|
||||
GRID_COUNT,
|
||||
SPACE_WIDTH,
|
||||
SPACE_HEIGHT,
|
||||
DATA_URL,
|
||||
REFRESH_INTERVAL,
|
||||
} from '../constants'
|
||||
|
||||
async function fetchData() {
|
||||
// TODO: stable idxs
|
||||
const resp = await fetch(DATA_URL)
|
||||
const text = await resp.text()
|
||||
const data = await csv().fromString(text)
|
||||
return data.filter((d) => d.Link && d.Status === 'Live')
|
||||
}
|
||||
|
||||
function main() {
|
||||
const mainWin = new BrowserWindow({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 800,
|
||||
height: 600,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
},
|
||||
})
|
||||
mainWin.loadFile('control.html')
|
||||
mainWin.webContents.on('will-navigate', (ev, url) => {
|
||||
ev.preventDefault()
|
||||
shell.openExternal(url)
|
||||
})
|
||||
|
||||
const streamWin = new BrowserWindow({
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
backgroundColor: '#000',
|
||||
useContentSize: true,
|
||||
show: false,
|
||||
})
|
||||
streamWin.removeMenu()
|
||||
streamWin.loadURL('about:blank')
|
||||
|
||||
// Work around https://github.com/electron/electron/issues/14308
|
||||
// via https://github.com/lutzroeder/netron/commit/910ce67395130690ad76382c094999a4f5b51e92
|
||||
streamWin.once('ready-to-show', () => {
|
||||
streamWin.resizable = false
|
||||
streamWin.show()
|
||||
})
|
||||
|
||||
const overlayView = new BrowserView({
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
},
|
||||
})
|
||||
streamWin.addBrowserView(overlayView)
|
||||
overlayView.setBounds({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
})
|
||||
overlayView.webContents.loadFile('overlay.html')
|
||||
|
||||
const actions = {
|
||||
hideView: (context, event) => {
|
||||
const { view } = context
|
||||
streamWin.removeBrowserView(view)
|
||||
},
|
||||
positionView: (context, event) => {
|
||||
const { pos, view } = context
|
||||
streamWin.addBrowserView(view)
|
||||
|
||||
// It's necessary to remove and re-add the overlay view to ensure it's on top.
|
||||
streamWin.removeBrowserView(overlayView)
|
||||
streamWin.addBrowserView(overlayView)
|
||||
|
||||
view.setBounds(pos)
|
||||
},
|
||||
}
|
||||
|
||||
const views = []
|
||||
for (let idx = 0; idx <= 9; idx++) {
|
||||
const view = new BrowserView()
|
||||
view.setBackgroundColor('#000')
|
||||
|
||||
const machine = viewStateMachine
|
||||
.withContext({
|
||||
...viewStateMachine.context,
|
||||
view,
|
||||
parentWin: streamWin,
|
||||
overlayView,
|
||||
})
|
||||
.withConfig({ actions })
|
||||
const service = interpret(machine).start()
|
||||
service.onTransition((state) => {
|
||||
overlayView.webContents.send('space-state', idx, {
|
||||
state: state.value,
|
||||
context: {
|
||||
url: state.context.url,
|
||||
info: state.context.info,
|
||||
bounds: state.context.pos,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
views.push(service)
|
||||
}
|
||||
|
||||
ipcMain.on('set-videos', async (ev, spaceURLMap) => {
|
||||
const boxes = boxesFromSpaceURLMap(GRID_COUNT, GRID_COUNT, spaceURLMap)
|
||||
|
||||
const unusedViews = new Set(views)
|
||||
for (const box of boxes) {
|
||||
const { url, x, y, w, h, spaces } = box
|
||||
// TODO: prefer fully loaded views
|
||||
let space = views.find(
|
||||
(s) => unusedViews.has(s) && s.state.context.url === url,
|
||||
)
|
||||
if (!space) {
|
||||
space = views.find(
|
||||
(s) => unusedViews.has(s) && !s.state.matches('displaying'),
|
||||
)
|
||||
}
|
||||
const pos = {
|
||||
x: SPACE_WIDTH * x,
|
||||
y: SPACE_HEIGHT * y,
|
||||
width: SPACE_WIDTH * w,
|
||||
height: SPACE_HEIGHT * h,
|
||||
spaces,
|
||||
}
|
||||
space.send({ type: 'DISPLAY', pos, url })
|
||||
unusedViews.delete(space)
|
||||
}
|
||||
|
||||
for (const space of unusedViews) {
|
||||
space.send('CLEAR')
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on('set-sound-source', async (ev, spaceIdx) => {
|
||||
for (const view of views) {
|
||||
if (!view.state.matches('displaying')) {
|
||||
continue
|
||||
}
|
||||
const { context } = view.state
|
||||
const isSelectedView = context.pos.spaces.includes(spaceIdx)
|
||||
view.send(isSelectedView ? 'UNMUTE' : 'MUTE')
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on('devtools-overlay', () => {
|
||||
overlayView.webContents.openDevTools()
|
||||
})
|
||||
|
||||
async function refreshData() {
|
||||
const data = await fetchData()
|
||||
mainWin.webContents.send('stream-data', data)
|
||||
overlayView.webContents.send('stream-data', data)
|
||||
}
|
||||
setInterval(refreshData, REFRESH_INTERVAL)
|
||||
refreshData()
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
app.whenReady().then(main)
|
||||
}
|
||||
201
src/node/viewStateMachine.js
Normal file
201
src/node/viewStateMachine.js
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Machine, assign } from 'xstate'
|
||||
|
||||
const viewStateMachine = Machine(
|
||||
{
|
||||
id: 'view',
|
||||
initial: 'empty',
|
||||
context: {
|
||||
view: null,
|
||||
pos: null,
|
||||
url: null,
|
||||
info: {},
|
||||
},
|
||||
on: {
|
||||
CLEAR: 'empty',
|
||||
DISPLAY: 'displaying',
|
||||
},
|
||||
states: {
|
||||
empty: {
|
||||
entry: [
|
||||
assign({
|
||||
pos: { url: null },
|
||||
info: {},
|
||||
}),
|
||||
'hideView',
|
||||
],
|
||||
invoke: {
|
||||
src: 'clearView',
|
||||
onError: {
|
||||
target: '#view.error',
|
||||
},
|
||||
},
|
||||
},
|
||||
displaying: {
|
||||
id: 'displaying',
|
||||
initial: 'loading',
|
||||
entry: assign({
|
||||
pos: (context, event) => event.pos,
|
||||
url: (context, event) => event.url,
|
||||
}),
|
||||
on: {
|
||||
DISPLAY: {
|
||||
actions: assign({
|
||||
pos: (context, event) => event.pos,
|
||||
}),
|
||||
cond: 'urlUnchanged',
|
||||
},
|
||||
},
|
||||
states: {
|
||||
loading: {
|
||||
initial: 'page',
|
||||
states: {
|
||||
page: {
|
||||
invoke: {
|
||||
src: 'loadURL',
|
||||
onDone: {
|
||||
target: 'video',
|
||||
},
|
||||
onError: {
|
||||
target: '#view.error',
|
||||
},
|
||||
},
|
||||
},
|
||||
video: {
|
||||
invoke: {
|
||||
src: 'startVideo',
|
||||
onDone: {
|
||||
target: '#view.displaying.running',
|
||||
actions: assign({
|
||||
info: (context, event) => event.data,
|
||||
}),
|
||||
},
|
||||
onError: {
|
||||
target: '#view.error',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
running: {
|
||||
initial: 'muted',
|
||||
entry: 'positionView',
|
||||
on: {
|
||||
DISPLAY: {
|
||||
actions: [
|
||||
assign({
|
||||
pos: (context, event) => event.pos,
|
||||
}),
|
||||
'positionView',
|
||||
],
|
||||
cond: 'urlUnchanged',
|
||||
},
|
||||
MUTE: '.muted',
|
||||
UNMUTE: '.listening',
|
||||
},
|
||||
states: {
|
||||
muted: {
|
||||
entry: 'muteAudio',
|
||||
},
|
||||
listening: {
|
||||
entry: 'unmuteAudio',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
error: {
|
||||
entry: 'logError',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
logError: (context, event) => {
|
||||
console.warn(event)
|
||||
},
|
||||
muteAudio: (context, event) => {
|
||||
context.view.webContents.audioMuted = true
|
||||
},
|
||||
unmuteAudio: (context, event) => {
|
||||
context.view.webContents.audioMuted = false
|
||||
},
|
||||
},
|
||||
guards: {
|
||||
urlUnchanged: (context, event) => {
|
||||
return context.url === event.url
|
||||
},
|
||||
},
|
||||
services: {
|
||||
clearView: async (context, event) => {
|
||||
await context.view.webContents.loadURL('about:blank')
|
||||
},
|
||||
loadURL: async (context, event) => {
|
||||
const { url, view } = context
|
||||
const wc = view.webContents
|
||||
wc.audioMuted = true
|
||||
await wc.loadURL(url)
|
||||
wc.insertCSS(
|
||||
`
|
||||
* {
|
||||
display: none !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
html, body, video {
|
||||
display: block !important;
|
||||
background: black !important;
|
||||
}
|
||||
html, body {
|
||||
overflow: hidden !important;
|
||||
background: black !important;
|
||||
}
|
||||
video {
|
||||
display: block !important;
|
||||
position: fixed !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
top: 0 !important;
|
||||
bottom: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
object-fit: cover !important;
|
||||
z-index: 999999 !important;
|
||||
}
|
||||
`,
|
||||
{ cssOrigin: 'user' },
|
||||
)
|
||||
},
|
||||
startVideo: async (context, event) => {
|
||||
const wc = context.view.webContents
|
||||
const info = await wc.executeJavaScript(`
|
||||
const sleep = ms => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
async function waitForVideo() {
|
||||
// Give the client side a little time to load. In particular, YouTube seems to need a delay.
|
||||
await sleep(1000)
|
||||
|
||||
let tries = 0
|
||||
let video
|
||||
while (!video && tries < 20) {
|
||||
video = document.querySelector('video')
|
||||
tries++
|
||||
await sleep(200)
|
||||
}
|
||||
if (!video) {
|
||||
throw new Error('could not find video')
|
||||
}
|
||||
document.body.appendChild(video)
|
||||
video.muted = false
|
||||
video.autoPlay = true
|
||||
video.play()
|
||||
setInterval(() => video.play(), 1000)
|
||||
const info = {title: document.title}
|
||||
return info
|
||||
}
|
||||
waitForVideo()
|
||||
`)
|
||||
return info
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export default viewStateMachine
|
||||
Reference in New Issue
Block a user