diff --git a/README.md b/README.md index 8596cbb..9bdc1c2 100644 --- a/README.md +++ b/README.md @@ -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 -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 diff --git a/example.config.toml b/example.config.toml index 3f581ce..5a0e67e 100644 --- a/example.config.toml +++ b/example.config.toml @@ -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) #background-color = "#0f0" +[grid] +#count = 3 + [control] # Address to serve control server from address = "http://localhost:80" diff --git a/src/browser/overlay.js b/src/browser/overlay.js index f189ca7..9a82138 100644 --- a/src/browser/overlay.js +++ b/src/browser/overlay.js @@ -7,7 +7,6 @@ import { useHotkeys } from 'react-hotkeys-hook' 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' @@ -16,7 +15,8 @@ import TwitchIcon from '../static/twitch.svg' import YouTubeIcon from '../static/youtube.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 .map(({ state, context }) => State.from(state, context)) .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 isLoading = viewState.matches('displaying.loading') return ( - + {data && ( @@ -60,6 +65,7 @@ function Overlay({ views, streams, customStreams }) { function App() { const [state, setState] = useState({ + config: {}, views: [], streams: [], customStreams: [], @@ -75,9 +81,14 @@ function App() { ipcRenderer.send('devtools-overlay') }) - const { views, streams, customStreams } = state + const { config, views, streams, customStreams } = state return ( - + ) } @@ -118,12 +129,12 @@ const SpaceBorder = styled.div.attrs((props) => ({ border: 0 solid black; border-left-width: ${({ pos, borderWidth }) => pos.x === 0 ? 0 : borderWidth}px; - border-right-width: ${({ pos, borderWidth }) => - pos.x + pos.width === WIDTH ? 0 : borderWidth}px; + border-right-width: ${({ pos, borderWidth, windowWidth }) => + pos.x + pos.width === windowWidth ? 0 : borderWidth}px; border-top-width: ${({ pos, borderWidth }) => pos.y === 0 ? 0 : borderWidth}px; - border-bottom-width: ${({ pos, borderWidth }) => - pos.y + pos.height === HEIGHT ? 0 : borderWidth}px; + border-bottom-width: ${({ pos, borderWidth, windowHeight }) => + pos.y + pos.height === windowHeight ? 0 : borderWidth}px; box-shadow: ${({ isListening }) => isListening ? '0 0 10px red inset' : 'none'}; box-sizing: border-box; diff --git a/src/constants.js b/src/constants.js deleted file mode 100644 index b2a46b0..0000000 --- a/src/constants.js +++ /dev/null @@ -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) diff --git a/src/node/StreamWindow.js b/src/node/StreamWindow.js index ebaf9f4..cb3ef10 100644 --- a/src/node/StreamWindow.js +++ b/src/node/StreamWindow.js @@ -7,18 +7,15 @@ import { interpret } from 'xstate' import viewStateMachine from './viewStateMachine' import { boxesFromViewContentMap } from './geometry' -import { - WIDTH, - HEIGHT, - GRID_COUNT, - SPACE_WIDTH, - SPACE_HEIGHT, -} from '../constants' - export default class StreamWindow extends EventEmitter { - constructor({ backgroundColor = '#000' }) { + constructor(config) { 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.offscreenWin = null this.overlayView = null @@ -27,11 +24,24 @@ export default class StreamWindow extends EventEmitter { } init() { + const { + width, + height, + x, + y, + frameless, + backgroundColor, + spaceWidth, + spaceHeight, + } = this.config const win = new BrowserWindow({ title: 'Streamwall', - width: WIDTH, - height: HEIGHT, - backgroundColor: this.backgroundColor, + width, + height, + x, + y, + frame: !frameless, + backgroundColor, useContentSize: true, show: false, }) @@ -63,8 +73,8 @@ export default class StreamWindow extends EventEmitter { overlayView.setBounds({ x: 0, y: 0, - width: WIDTH, - height: HEIGHT, + width, + height, }) overlayView.webContents.loadFile('overlay.html') 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. win.removeBrowserView(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) => { const { pos, view } = context @@ -96,6 +106,7 @@ export default class StreamWindow extends EventEmitter { createView() { const { win, overlayView, viewActions } = this + const { backgroundColor } = this.config const view = new BrowserView({ webPreferences: { nodeIntegration: false, @@ -104,7 +115,7 @@ export default class StreamWindow extends EventEmitter { sandbox: true, }, }) - view.setBackgroundColor(this.backgroundColor) + view.setBackgroundColor(backgroundColor) const machine = viewStateMachine .withContext({ @@ -135,12 +146,9 @@ export default class StreamWindow extends EventEmitter { } setViews(viewContentMap) { + const { gridCount, spaceWidth, spaceHeight } = this.config const { win, views } = this - const boxes = boxesFromViewContentMap( - GRID_COUNT, - GRID_COUNT, - viewContentMap, - ) + const boxes = boxesFromViewContentMap(gridCount, gridCount, viewContentMap) const remainingBoxes = new Set(boxes) const unusedViews = new Set(views) const viewsToDisplay = [] @@ -187,10 +195,10 @@ export default class StreamWindow extends EventEmitter { for (const { box, view } of viewsToDisplay) { const { content, x, y, w, h, spaces } = box const pos = { - x: SPACE_WIDTH * x, - y: SPACE_HEIGHT * y, - width: SPACE_WIDTH * w, - height: SPACE_HEIGHT * h, + x: spaceWidth * x, + y: spaceHeight * y, + width: spaceWidth * w, + height: spaceHeight * h, spaces, } view.send({ type: 'DISPLAY', pos, content }) diff --git a/src/node/index.js b/src/node/index.js index 3fcb591..1f5b09b 100644 --- a/src/node/index.js +++ b/src/node/index.js @@ -21,7 +21,41 @@ function parseArgs() { .config('config', (configPath) => { 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)', default: '#000', }) @@ -118,7 +152,13 @@ async function main() { }) 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() @@ -126,6 +166,11 @@ async function main() { let streamdelayClient = null const clientState = { + config: { + width: argv.window.width, + height: argv.window.height, + gridCount: argv.grid.count, + }, streams: [], customStreams: [], views: [], diff --git a/src/web/control.js b/src/web/control.js index 15dd830..eaf831e 100644 --- a/src/web/control.js +++ b/src/web/control.js @@ -9,16 +9,39 @@ import styled, { css } from 'styled-components' import { useHotkeys } from 'react-hotkeys-hook' import '../index.css' -import { GRID_COUNT } from '../constants' import SoundIcon from '../static/volume-up-solid.svg' import NoVideoIcon from '../static/video-slash-solid.svg' import ReloadIcon from '../static/redo-alt-solid.svg' import LifeRingIcon from '../static/life-ring-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 }) { const wsRef = useRef() const [isConnected, setIsConnected] = useState(false) + const [config, setConfig] = useState({}) const [streams, setStreams] = useState([]) const [customStreams, setCustomStreams] = useState([]) const [stateIdxMap, setStateIdxMap] = useState(new Map()) @@ -26,6 +49,8 @@ function App({ wsEndpoint }) { isConnected: false, }) + const { gridCount } = config + useEffect(() => { const ws = new ReconnectingWebSocket(wsEndpoint, [], { maxReconnectionDelay: 5000, @@ -37,7 +62,12 @@ function App({ wsEndpoint }) { ws.addEventListener('message', (ev) => { const msg = JSON.parse(ev.data) if (msg.type === 'state') { - const { streams: newStreams, views, streamdelay } = msg.state + const { + config: newConfig, + streams: newStreams, + views, + streamdelay, + } = msg.state const newStateIdxMap = new Map() for (const viewState of views) { const { pos, content } = viewState.context @@ -61,6 +91,7 @@ function App({ wsEndpoint }) { }) } } + setConfig(newConfig) setStateIdxMap(newStateIdxMap) setStreams(sortBy(newStreams, ['_id'])) setCustomStreams(newStreams.filter((s) => s._dataSource === 'custom')) @@ -149,15 +180,18 @@ function App({ wsEndpoint }) { ) }, []) - const handleClickId = useCallback((streamId) => { - const availableIdx = range(GRID_COUNT * GRID_COUNT).find( - (i) => !stateIdxMap.has(i), - ) - if (availableIdx === undefined) { - return - } - handleSetView(availableIdx, streamId) - }) + const handleClickId = useCallback( + (streamId) => { + const availableIdx = range(gridCount * gridCount).find( + (i) => !stateIdxMap.has(i), + ) + if (availableIdx === undefined) { + return + } + handleSetView(availableIdx, streamId) + }, + [gridCount, stateIdxMap], + ) const handleChangeCustomStream = useCallback((idx, customStream) => { let newCustomStreams = [...customStreams] @@ -181,25 +215,26 @@ function App({ wsEndpoint }) { }, []) // Set up keyboard shortcuts. - // Note: if GRID_COUNT > 3, there will not be keys for view indices > 9. - for (const idx of range(GRID_COUNT * GRID_COUNT)) { - useHotkeys( - `alt+${idx + 1}`, - () => { - const isListening = stateIdxMap.get(idx)?.isListening ?? false - handleSetListening(idx, !isListening) - }, - [stateIdxMap], - ) - useHotkeys( - `alt+shift+${idx + 1}`, - () => { - const isBlurred = stateIdxMap.get(idx)?.isBlurred ?? false - handleSetBlurred(idx, !isBlurred) - }, - [stateIdxMap], - ) - } + useHotkeys( + hotkeyTriggers.map((k) => `alt+${k}`).join(','), + (ev, { key }) => { + ev.preventDefault() + const idx = hotkeyTriggers.indexOf(key[key.length - 1]) + const isListening = stateIdxMap.get(idx)?.isListening ?? false + handleSetListening(idx, !isListening) + }, + [stateIdxMap], + ) + useHotkeys( + hotkeyTriggers.map((k) => `alt+shift+${k}`).join(','), + (ev, { key }) => { + ev.preventDefault() + const idx = hotkeyTriggers.indexOf(key[key.length - 1]) + const isBlurred = stateIdxMap.get(idx)?.isBlurred ?? false + handleSetBlurred(idx, !isBlurred) + }, + [stateIdxMap], + ) useHotkeys( `alt+c`, () => { @@ -227,10 +262,10 @@ function App({ wsEndpoint }) { />
- {range(0, GRID_COUNT).map((y) => ( + {range(0, gridCount).map((y) => ( - {range(0, GRID_COUNT).map((x) => { - const idx = GRID_COUNT * y + x + {range(0, gridCount).map((x) => { + const idx = gridCount * y + x const { streamId = '', isListening = false,