Make window position, frame, and grid count configurable

This commit is contained in:
Max Goodhart
2020-07-01 22:39:45 -07:00
parent fd64281676
commit a0fcc4e8a8
7 changed files with 187 additions and 76 deletions

View File

@@ -70,7 +70,11 @@ We've observed this occur in cases where file corruption is an issue. The fix ha
### The Streamwall Electron window only fits 2.5 tiles wide ### The Streamwall Electron window only fits 2.5 tiles wide
It's possible that your system resolution is causing a problem. If you only broadcast at 720p, you can update the height and width in `src/constants.js` to 1280 and 720 respectively. Save your changes and restart Streamwall Streamwall in its default settings needs enough screen space to display a 1920x1080 (1080p) window, with room for the titlebar. You can configure Streamwall to open a smaller window:
```
npm start -- --window.width=1024 --window.height=768
```
## Credits ## Credits

View File

@@ -1,6 +1,19 @@
[window]
# Window dimensions
#width = 1920
#height = 1080
# Position a frameless window (useful for capturing w/ fixed screen positions)
#x = 0
#y = 0
#frameless = false
# Set the background color (useful for chroma-keying) # Set the background color (useful for chroma-keying)
#background-color = "#0f0" #background-color = "#0f0"
[grid]
#count = 3
[control] [control]
# Address to serve control server from # Address to serve control server from
address = "http://localhost:80" address = "http://localhost:80"

View File

@@ -7,7 +7,6 @@ import { useHotkeys } from 'react-hotkeys-hook'
import { TailSpin } from 'svg-loaders-react' import { TailSpin } from 'svg-loaders-react'
import '../index.css' import '../index.css'
import { WIDTH, HEIGHT } from '../constants'
import InstagramIcon from '../static/instagram.svg' import InstagramIcon from '../static/instagram.svg'
import FacebookIcon from '../static/facebook.svg' import FacebookIcon from '../static/facebook.svg'
@@ -16,7 +15,8 @@ import TwitchIcon from '../static/twitch.svg'
import YouTubeIcon from '../static/youtube.svg' import YouTubeIcon from '../static/youtube.svg'
import SoundIcon from '../static/volume-up-solid.svg' import SoundIcon from '../static/volume-up-solid.svg'
function Overlay({ views, streams, customStreams }) { function Overlay({ config, views, streams, customStreams }) {
const { width, height } = config
const activeViews = views const activeViews = views
.map(({ state, context }) => State.from(state, context)) .map(({ state, context }) => State.from(state, context))
.filter((s) => s.matches('displaying') && !s.matches('displaying.error')) .filter((s) => s.matches('displaying') && !s.matches('displaying.error'))
@@ -33,7 +33,12 @@ function Overlay({ views, streams, customStreams }) {
const isBlurred = viewState.matches('displaying.running.video.blurred') const isBlurred = viewState.matches('displaying.running.video.blurred')
const isLoading = viewState.matches('displaying.loading') const isLoading = viewState.matches('displaying.loading')
return ( return (
<SpaceBorder pos={pos} isListening={isListening}> <SpaceBorder
pos={pos}
windowWidth={width}
windowHeight={height}
isListening={isListening}
>
<BlurCover isBlurred={isBlurred} /> <BlurCover isBlurred={isBlurred} />
{data && ( {data && (
<StreamTitle isListening={isListening}> <StreamTitle isListening={isListening}>
@@ -60,6 +65,7 @@ function Overlay({ views, streams, customStreams }) {
function App() { function App() {
const [state, setState] = useState({ const [state, setState] = useState({
config: {},
views: [], views: [],
streams: [], streams: [],
customStreams: [], customStreams: [],
@@ -75,9 +81,14 @@ function App() {
ipcRenderer.send('devtools-overlay') ipcRenderer.send('devtools-overlay')
}) })
const { views, streams, customStreams } = state const { config, views, streams, customStreams } = state
return ( return (
<Overlay views={views} streams={streams} customStreams={customStreams} /> <Overlay
config={config}
views={views}
streams={streams}
customStreams={customStreams}
/>
) )
} }
@@ -118,12 +129,12 @@ const SpaceBorder = styled.div.attrs((props) => ({
border: 0 solid black; border: 0 solid black;
border-left-width: ${({ pos, borderWidth }) => border-left-width: ${({ pos, borderWidth }) =>
pos.x === 0 ? 0 : borderWidth}px; pos.x === 0 ? 0 : borderWidth}px;
border-right-width: ${({ pos, borderWidth }) => border-right-width: ${({ pos, borderWidth, windowWidth }) =>
pos.x + pos.width === WIDTH ? 0 : borderWidth}px; pos.x + pos.width === windowWidth ? 0 : borderWidth}px;
border-top-width: ${({ pos, borderWidth }) => border-top-width: ${({ pos, borderWidth }) =>
pos.y === 0 ? 0 : borderWidth}px; pos.y === 0 ? 0 : borderWidth}px;
border-bottom-width: ${({ pos, borderWidth }) => border-bottom-width: ${({ pos, borderWidth, windowHeight }) =>
pos.y + pos.height === HEIGHT ? 0 : borderWidth}px; pos.y + pos.height === windowHeight ? 0 : borderWidth}px;
box-shadow: ${({ isListening }) => box-shadow: ${({ isListening }) =>
isListening ? '0 0 10px red inset' : 'none'}; isListening ? '0 0 10px red inset' : 'none'};
box-sizing: border-box; box-sizing: border-box;

View File

@@ -1,5 +0,0 @@
export const WIDTH = 1920
export const HEIGHT = 1080
export const GRID_COUNT = 3 // Note: if greater than 3, keyboard shortcuts may need reworking
export const SPACE_WIDTH = Math.floor(WIDTH / GRID_COUNT)
export const SPACE_HEIGHT = Math.floor(HEIGHT / GRID_COUNT)

View File

@@ -7,18 +7,15 @@ import { interpret } from 'xstate'
import viewStateMachine from './viewStateMachine' import viewStateMachine from './viewStateMachine'
import { boxesFromViewContentMap } from './geometry' import { boxesFromViewContentMap } from './geometry'
import {
WIDTH,
HEIGHT,
GRID_COUNT,
SPACE_WIDTH,
SPACE_HEIGHT,
} from '../constants'
export default class StreamWindow extends EventEmitter { export default class StreamWindow extends EventEmitter {
constructor({ backgroundColor = '#000' }) { constructor(config) {
super() super()
this.backgroundColor = backgroundColor
const { width, height, gridCount } = config
config.spaceWidth = Math.floor(width / gridCount)
config.spaceHeight = Math.floor(height / gridCount)
this.config = config
this.win = null this.win = null
this.offscreenWin = null this.offscreenWin = null
this.overlayView = null this.overlayView = null
@@ -27,11 +24,24 @@ export default class StreamWindow extends EventEmitter {
} }
init() { init() {
const {
width,
height,
x,
y,
frameless,
backgroundColor,
spaceWidth,
spaceHeight,
} = this.config
const win = new BrowserWindow({ const win = new BrowserWindow({
title: 'Streamwall', title: 'Streamwall',
width: WIDTH, width,
height: HEIGHT, height,
backgroundColor: this.backgroundColor, x,
y,
frame: !frameless,
backgroundColor,
useContentSize: true, useContentSize: true,
show: false, show: false,
}) })
@@ -63,8 +73,8 @@ export default class StreamWindow extends EventEmitter {
overlayView.setBounds({ overlayView.setBounds({
x: 0, x: 0,
y: 0, y: 0,
width: WIDTH, width,
height: HEIGHT, height,
}) })
overlayView.webContents.loadFile('overlay.html') overlayView.webContents.loadFile('overlay.html')
this.overlayView = overlayView this.overlayView = overlayView
@@ -75,7 +85,7 @@ export default class StreamWindow extends EventEmitter {
// It appears necessary to initialize the browser view by adding it to a window and setting bounds. Otherwise, some streaming sites like Periscope will not load their videos due to the Page Visibility API being hidden. // It appears necessary to initialize the browser view by adding it to a window and setting bounds. Otherwise, some streaming sites like Periscope will not load their videos due to the Page Visibility API being hidden.
win.removeBrowserView(view) win.removeBrowserView(view)
offscreenWin.addBrowserView(view) offscreenWin.addBrowserView(view)
view.setBounds({ x: 0, y: 0, width: SPACE_WIDTH, height: SPACE_HEIGHT }) view.setBounds({ x: 0, y: 0, width: spaceWidth, height: spaceHeight })
}, },
positionView: (context, event) => { positionView: (context, event) => {
const { pos, view } = context const { pos, view } = context
@@ -96,6 +106,7 @@ export default class StreamWindow extends EventEmitter {
createView() { createView() {
const { win, overlayView, viewActions } = this const { win, overlayView, viewActions } = this
const { backgroundColor } = this.config
const view = new BrowserView({ const view = new BrowserView({
webPreferences: { webPreferences: {
nodeIntegration: false, nodeIntegration: false,
@@ -104,7 +115,7 @@ export default class StreamWindow extends EventEmitter {
sandbox: true, sandbox: true,
}, },
}) })
view.setBackgroundColor(this.backgroundColor) view.setBackgroundColor(backgroundColor)
const machine = viewStateMachine const machine = viewStateMachine
.withContext({ .withContext({
@@ -135,12 +146,9 @@ export default class StreamWindow extends EventEmitter {
} }
setViews(viewContentMap) { setViews(viewContentMap) {
const { gridCount, spaceWidth, spaceHeight } = this.config
const { win, views } = this const { win, views } = this
const boxes = boxesFromViewContentMap( const boxes = boxesFromViewContentMap(gridCount, gridCount, viewContentMap)
GRID_COUNT,
GRID_COUNT,
viewContentMap,
)
const remainingBoxes = new Set(boxes) const remainingBoxes = new Set(boxes)
const unusedViews = new Set(views) const unusedViews = new Set(views)
const viewsToDisplay = [] const viewsToDisplay = []
@@ -187,10 +195,10 @@ export default class StreamWindow extends EventEmitter {
for (const { box, view } of viewsToDisplay) { for (const { box, view } of viewsToDisplay) {
const { content, x, y, w, h, spaces } = box const { content, x, y, w, h, spaces } = box
const pos = { const pos = {
x: SPACE_WIDTH * x, x: spaceWidth * x,
y: SPACE_HEIGHT * y, y: spaceHeight * y,
width: SPACE_WIDTH * w, width: spaceWidth * w,
height: SPACE_HEIGHT * h, height: spaceHeight * h,
spaces, spaces,
} }
view.send({ type: 'DISPLAY', pos, content }) view.send({ type: 'DISPLAY', pos, content })

View File

@@ -21,7 +21,41 @@ function parseArgs() {
.config('config', (configPath) => { .config('config', (configPath) => {
return TOML.parse(fs.readFileSync(configPath, 'utf-8')) return TOML.parse(fs.readFileSync(configPath, 'utf-8'))
}) })
.option('background-color', { .group(['grid.count'], 'Grid dimensions')
.option('grid.count', {
number: true,
default: 3,
})
.group(
[
'window.width',
'window.height',
'window.x',
'window.y',
'window.frameless',
'window.background-color',
],
'Window settings',
)
.option('window.x', {
number: true,
})
.option('window.y', {
number: true,
})
.option('window.width', {
number: true,
default: 1920,
})
.option('window.height', {
number: true,
default: 1080,
})
.option('window.frameless', {
boolean: true,
default: false,
})
.option('window.background-color', {
describe: 'Background color of wall (useful for chroma-keying)', describe: 'Background color of wall (useful for chroma-keying)',
default: '#000', default: '#000',
}) })
@@ -118,7 +152,13 @@ async function main() {
}) })
const streamWindow = new StreamWindow({ const streamWindow = new StreamWindow({
backgroundColor: argv.backgroundColor, gridCount: argv.grid.count,
width: argv.window.width,
height: argv.window.height,
x: argv.window.x,
y: argv.window.y,
frameless: argv.window.frameless,
backgroundColor: argv.window['background-color'],
}) })
streamWindow.init() streamWindow.init()
@@ -126,6 +166,11 @@ async function main() {
let streamdelayClient = null let streamdelayClient = null
const clientState = { const clientState = {
config: {
width: argv.window.width,
height: argv.window.height,
gridCount: argv.grid.count,
},
streams: [], streams: [],
customStreams: [], customStreams: [],
views: [], views: [],

View File

@@ -9,16 +9,39 @@ import styled, { css } from 'styled-components'
import { useHotkeys } from 'react-hotkeys-hook' import { useHotkeys } from 'react-hotkeys-hook'
import '../index.css' import '../index.css'
import { GRID_COUNT } from '../constants'
import SoundIcon from '../static/volume-up-solid.svg' import SoundIcon from '../static/volume-up-solid.svg'
import NoVideoIcon from '../static/video-slash-solid.svg' import NoVideoIcon from '../static/video-slash-solid.svg'
import ReloadIcon from '../static/redo-alt-solid.svg' import ReloadIcon from '../static/redo-alt-solid.svg'
import LifeRingIcon from '../static/life-ring-regular.svg' import LifeRingIcon from '../static/life-ring-regular.svg'
import WindowIcon from '../static/window-maximize-regular.svg' import WindowIcon from '../static/window-maximize-regular.svg'
const hotkeyTriggers = [
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'0',
'q',
'w',
'e',
'r',
't',
'y',
'u',
'i',
'o',
'p',
]
function App({ wsEndpoint }) { function App({ wsEndpoint }) {
const wsRef = useRef() const wsRef = useRef()
const [isConnected, setIsConnected] = useState(false) const [isConnected, setIsConnected] = useState(false)
const [config, setConfig] = useState({})
const [streams, setStreams] = useState([]) const [streams, setStreams] = useState([])
const [customStreams, setCustomStreams] = useState([]) const [customStreams, setCustomStreams] = useState([])
const [stateIdxMap, setStateIdxMap] = useState(new Map()) const [stateIdxMap, setStateIdxMap] = useState(new Map())
@@ -26,6 +49,8 @@ function App({ wsEndpoint }) {
isConnected: false, isConnected: false,
}) })
const { gridCount } = config
useEffect(() => { useEffect(() => {
const ws = new ReconnectingWebSocket(wsEndpoint, [], { const ws = new ReconnectingWebSocket(wsEndpoint, [], {
maxReconnectionDelay: 5000, maxReconnectionDelay: 5000,
@@ -37,7 +62,12 @@ function App({ wsEndpoint }) {
ws.addEventListener('message', (ev) => { ws.addEventListener('message', (ev) => {
const msg = JSON.parse(ev.data) const msg = JSON.parse(ev.data)
if (msg.type === 'state') { if (msg.type === 'state') {
const { streams: newStreams, views, streamdelay } = msg.state const {
config: newConfig,
streams: newStreams,
views,
streamdelay,
} = msg.state
const newStateIdxMap = new Map() const newStateIdxMap = new Map()
for (const viewState of views) { for (const viewState of views) {
const { pos, content } = viewState.context const { pos, content } = viewState.context
@@ -61,6 +91,7 @@ function App({ wsEndpoint }) {
}) })
} }
} }
setConfig(newConfig)
setStateIdxMap(newStateIdxMap) setStateIdxMap(newStateIdxMap)
setStreams(sortBy(newStreams, ['_id'])) setStreams(sortBy(newStreams, ['_id']))
setCustomStreams(newStreams.filter((s) => s._dataSource === 'custom')) setCustomStreams(newStreams.filter((s) => s._dataSource === 'custom'))
@@ -149,15 +180,18 @@ function App({ wsEndpoint }) {
) )
}, []) }, [])
const handleClickId = useCallback((streamId) => { const handleClickId = useCallback(
const availableIdx = range(GRID_COUNT * GRID_COUNT).find( (streamId) => {
(i) => !stateIdxMap.has(i), const availableIdx = range(gridCount * gridCount).find(
) (i) => !stateIdxMap.has(i),
if (availableIdx === undefined) { )
return if (availableIdx === undefined) {
} return
handleSetView(availableIdx, streamId) }
}) handleSetView(availableIdx, streamId)
},
[gridCount, stateIdxMap],
)
const handleChangeCustomStream = useCallback((idx, customStream) => { const handleChangeCustomStream = useCallback((idx, customStream) => {
let newCustomStreams = [...customStreams] let newCustomStreams = [...customStreams]
@@ -181,25 +215,26 @@ function App({ wsEndpoint }) {
}, []) }, [])
// Set up keyboard shortcuts. // Set up keyboard shortcuts.
// Note: if GRID_COUNT > 3, there will not be keys for view indices > 9. useHotkeys(
for (const idx of range(GRID_COUNT * GRID_COUNT)) { hotkeyTriggers.map((k) => `alt+${k}`).join(','),
useHotkeys( (ev, { key }) => {
`alt+${idx + 1}`, ev.preventDefault()
() => { const idx = hotkeyTriggers.indexOf(key[key.length - 1])
const isListening = stateIdxMap.get(idx)?.isListening ?? false const isListening = stateIdxMap.get(idx)?.isListening ?? false
handleSetListening(idx, !isListening) handleSetListening(idx, !isListening)
}, },
[stateIdxMap], [stateIdxMap],
) )
useHotkeys( useHotkeys(
`alt+shift+${idx + 1}`, hotkeyTriggers.map((k) => `alt+shift+${k}`).join(','),
() => { (ev, { key }) => {
const isBlurred = stateIdxMap.get(idx)?.isBlurred ?? false ev.preventDefault()
handleSetBlurred(idx, !isBlurred) const idx = hotkeyTriggers.indexOf(key[key.length - 1])
}, const isBlurred = stateIdxMap.get(idx)?.isBlurred ?? false
[stateIdxMap], handleSetBlurred(idx, !isBlurred)
) },
} [stateIdxMap],
)
useHotkeys( useHotkeys(
`alt+c`, `alt+c`,
() => { () => {
@@ -227,10 +262,10 @@ function App({ wsEndpoint }) {
/> />
<StyledDataContainer isConnected={isConnected}> <StyledDataContainer isConnected={isConnected}>
<div> <div>
{range(0, GRID_COUNT).map((y) => ( {range(0, gridCount).map((y) => (
<StyledGridLine> <StyledGridLine>
{range(0, GRID_COUNT).map((x) => { {range(0, gridCount).map((x) => {
const idx = GRID_COUNT * y + x const idx = gridCount * y + x
const { const {
streamId = '', streamId = '',
isListening = false, isListening = false,