Initial release
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* text=auto
|
||||||
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
7
LICENSE
Normal 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
@@ -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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
## 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
@@ -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
43
package.json
Normal 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
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
trailingComma: 'all',
|
||||||
|
tabWidth: 2,
|
||||||
|
semi: false,
|
||||||
|
singleQuote: true,
|
||||||
|
}
|
||||||
BIN
screenshot.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
3
src/browser/.babelrc.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"presets": ["@babel/preset-env"]
|
||||||
|
}
|
||||||
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
@@ -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
@@ -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
@@ -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
@@ -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-SemiBold.ttf
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"presets": [
|
||||||
|
[
|
||||||
|
"@babel/preset-env",
|
||||||
|
{
|
||||||
|
"modules": "commonjs",
|
||||||
|
"targets": {
|
||||||
|
"node": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
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
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||||