mirror of
https://github.com/streamwall/streamwall.git
synced 2025-12-06 01:45:37 -05:00
Make window position, frame, and grid count configurable
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 (
|
||||
<SpaceBorder pos={pos} isListening={isListening}>
|
||||
<SpaceBorder
|
||||
pos={pos}
|
||||
windowWidth={width}
|
||||
windowHeight={height}
|
||||
isListening={isListening}
|
||||
>
|
||||
<BlurCover isBlurred={isBlurred} />
|
||||
{data && (
|
||||
<StreamTitle isListening={isListening}>
|
||||
@@ -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 (
|
||||
<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-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;
|
||||
|
||||
@@ -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)
|
||||
@@ -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 })
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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 }) {
|
||||
/>
|
||||
<StyledDataContainer isConnected={isConnected}>
|
||||
<div>
|
||||
{range(0, GRID_COUNT).map((y) => (
|
||||
{range(0, gridCount).map((y) => (
|
||||
<StyledGridLine>
|
||||
{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,
|
||||
|
||||
Reference in New Issue
Block a user