Initial release

This commit is contained in:
Max Goodhart
2020-06-14 22:50:49 -07:00
commit ba794aa117
30 changed files with 12625 additions and 0 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
dist

7
LICENSE Normal file
View File

@@ -0,0 +1,7 @@
Copyright 2020 Max Goodhart
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

23
README.md Normal file
View File

@@ -0,0 +1,23 @@
# Streamwall
:construction: Early WIP release! :construction:
Streamwall makes it easy to compose multiple livestreams into a mosaic, with source attributions and audio control.
![Screenshot of Streamwall displaying a grid of streams](screenshot.png)
## How it works
Under the hood, think of Streamwall as a specialized web browser for mosaicing video streams. It uses [Electron](https://www.electronjs.org) to create a grid of web browser views, loading the specified webpages into them. Once the page loads, Streamwall finds the `<video>` tag and reformats the page so that the video fills the space. This works for a wide variety of web pages without specialized scrapers.
## Setup
1. Install dependencies: `npm install`
2. Launch: `npm start`
## Credits
SVG Icons are from Font Awesome by Dave Gandy - http://fontawesome.io

24
babel.config.json Normal file
View File

@@ -0,0 +1,24 @@
{
"presets": [
[
"@babel/preset-env",
{
"modules": "commonjs",
"targets": {
"electron": "9",
"node": true
}
}
]
],
"plugins": [
"babel-plugin-styled-components",
[
"@babel/plugin-transform-react-jsx",
{
"pragma": "h",
"pragmaFrag": "Fragment"
}
]
]
}

11479
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "streamwall",
"version": "0.0.1",
"description": "View streams in a grid",
"main": "src/index.js",
"scripts": {
"start": "webpack && electron dist",
"test": "jest"
},
"author": "Max Goodhart <c@chromakode.com>",
"license": "MIT",
"dependencies": {
"csvtojson": "^2.0.10",
"electron": "^9.0.4",
"lodash": "^4.17.15",
"mousetrap": "^1.6.5",
"node-fetch": "^2.6.0",
"preact": "^10.4.4",
"styled-components": "^5.1.1",
"svg-loaders-react": "^2.2.1",
"xstate": "^4.10.0"
},
"devDependencies": {
"@babel/core": "^7.10.2",
"@babel/plugin-transform-react-jsx": "^7.10.1",
"@babel/preset-env": "^7.10.2",
"@svgr/webpack": "^5.4.0",
"babel-jest": "^26.0.1",
"babel-loader": "^8.1.0",
"babel-plugin-styled-components": "^1.10.7",
"copy-webpack-plugin": "^6.0.2",
"css-loader": "^3.6.0",
"file-loader": "^6.0.0",
"jest": "^26.0.1",
"prettier": "2.0.5",
"style-loader": "^1.2.1",
"webpack": "^4.43.0",
"webpack-cli": "^3.3.11"
},
"browserslist": [
"electron 9.0"
]
}

6
prettier.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
trailingComma: 'all',
tabWidth: 2,
semi: false,
singleQuote: true,
}

BIN
screenshot.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-env"]
}

14
src/browser/control.html Normal file
View 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
View 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
View 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
View 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
View 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} &ndash; {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)

Binary file not shown.

Binary file not shown.

View 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

View 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

View 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

View 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

View 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

View 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
View 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
View File

@@ -0,0 +1,13 @@
{
"presets": [
[
"@babel/preset-env",
{
"modules": "commonjs",
"targets": {
"node": true
}
}
]
]
}

54
src/node/geometry.js Normal file
View 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
View 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
View 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)
}

View 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

70
webpack.config.js Normal file
View File

@@ -0,0 +1,70 @@
const CopyPlugin = require('copy-webpack-plugin')
const baseConfig = {
mode: 'development',
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
loader: 'babel-loader',
},
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
},
{
test: /\.ttf$/,
loader: 'file-loader',
options: {
name: '[name].[ext]',
},
},
{
test: /\.svg$/,
loader: '@svgr/webpack',
options: {
replaceAttrValues: {
'#333': 'currentColor',
'#555': '{props.color}',
},
},
},
],
},
}
const nodeConfig = {
...baseConfig,
target: 'electron-main',
entry: {
index: './src/node/index.js',
},
}
const browserConfig = {
...baseConfig,
devtool: 'cheap-source-map',
target: 'electron-renderer',
entry: {
control: './src/browser/control.js',
overlay: './src/browser/overlay.js',
},
resolve: {
extensions: ['.jsx', '.js'],
alias: {
react: 'preact/compat',
'react-dom': 'preact/compat',
},
},
plugins: [
new CopyPlugin({
patterns: [
{ from: 'src/**/*.html', to: '[name].html' },
{ from: 'src/**/*.ttf', to: '[name].ttf' },
],
}),
],
}
module.exports = [nodeConfig, browserConfig]