mirror of
https://github.com/streamwall/streamwall.git
synced 2026-04-03 20:32:08 -04:00
Improve control page UX for high grid counts
Thanks to mashed_potatoes for originally proposing this design.
This commit is contained in:
@@ -106,6 +106,7 @@ function useStreamwallConnection(wsEndpoint) {
|
|||||||
const [config, setConfig] = useState({})
|
const [config, setConfig] = useState({})
|
||||||
const [streams, setStreams] = useState([])
|
const [streams, setStreams] = useState([])
|
||||||
const [customStreams, setCustomStreams] = useState([])
|
const [customStreams, setCustomStreams] = useState([])
|
||||||
|
const [views, setViews] = useState([])
|
||||||
const [stateIdxMap, setStateIdxMap] = useState(new Map())
|
const [stateIdxMap, setStateIdxMap] = useState(new Map())
|
||||||
const [delayState, setDelayState] = useState()
|
const [delayState, setDelayState] = useState()
|
||||||
const [authState, setAuthState] = useState()
|
const [authState, setAuthState] = useState()
|
||||||
@@ -152,9 +153,10 @@ function useStreamwallConnection(wsEndpoint) {
|
|||||||
auth,
|
auth,
|
||||||
} = state
|
} = state
|
||||||
const newStateIdxMap = new Map()
|
const newStateIdxMap = new Map()
|
||||||
|
const newViews = []
|
||||||
for (const viewState of views) {
|
for (const viewState of views) {
|
||||||
const { pos } = viewState.context
|
const { pos } = viewState.context
|
||||||
const state = State.from(viewState.state)
|
const state = State.from(viewState.state, viewState.context)
|
||||||
const isListening = state.matches(
|
const isListening = state.matches(
|
||||||
'displaying.running.audio.listening',
|
'displaying.running.audio.listening',
|
||||||
)
|
)
|
||||||
@@ -162,22 +164,25 @@ function useStreamwallConnection(wsEndpoint) {
|
|||||||
'displaying.running.audio.background',
|
'displaying.running.audio.background',
|
||||||
)
|
)
|
||||||
const isBlurred = state.matches('displaying.running.video.blurred')
|
const isBlurred = state.matches('displaying.running.video.blurred')
|
||||||
for (const space of pos.spaces) {
|
const viewInfo = {
|
||||||
if (!newStateIdxMap.has(space)) {
|
|
||||||
newStateIdxMap.set(space, {})
|
|
||||||
}
|
|
||||||
Object.assign(newStateIdxMap.get(space), {
|
|
||||||
state,
|
state,
|
||||||
isListening,
|
isListening,
|
||||||
isBackgroundListening,
|
isBackgroundListening,
|
||||||
isBlurred,
|
isBlurred,
|
||||||
spaces: pos.spaces,
|
spaces: pos.spaces,
|
||||||
})
|
}
|
||||||
|
newViews.push(viewInfo)
|
||||||
|
for (const space of pos.spaces) {
|
||||||
|
if (!newStateIdxMap.has(space)) {
|
||||||
|
newStateIdxMap.set(space, {})
|
||||||
|
}
|
||||||
|
Object.assign(newStateIdxMap.get(space), viewInfo)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setConfig(newConfig)
|
setConfig(newConfig)
|
||||||
setStateIdxMap(newStateIdxMap)
|
setStateIdxMap(newStateIdxMap)
|
||||||
setStreams(sortBy(newStreams, ['_id']))
|
setStreams(sortBy(newStreams, ['_id']))
|
||||||
|
setViews(newViews)
|
||||||
setCustomStreams(newStreams.filter((s) => s._dataSource === 'custom'))
|
setCustomStreams(newStreams.filter((s) => s._dataSource === 'custom'))
|
||||||
setDelayState(
|
setDelayState(
|
||||||
streamdelay && {
|
streamdelay && {
|
||||||
@@ -236,6 +241,7 @@ function useStreamwallConnection(wsEndpoint) {
|
|||||||
config,
|
config,
|
||||||
streams,
|
streams,
|
||||||
customStreams,
|
customStreams,
|
||||||
|
views,
|
||||||
stateIdxMap,
|
stateIdxMap,
|
||||||
delayState,
|
delayState,
|
||||||
authState,
|
authState,
|
||||||
@@ -251,11 +257,12 @@ function App({ wsEndpoint, role }) {
|
|||||||
config,
|
config,
|
||||||
streams,
|
streams,
|
||||||
customStreams,
|
customStreams,
|
||||||
|
views,
|
||||||
stateIdxMap,
|
stateIdxMap,
|
||||||
delayState,
|
delayState,
|
||||||
authState,
|
authState,
|
||||||
} = useStreamwallConnection(wsEndpoint)
|
} = useStreamwallConnection(wsEndpoint)
|
||||||
const { gridCount } = config
|
const { gridCount, width: windowWidth, height: windowHeight } = config
|
||||||
|
|
||||||
const [showDebug, setShowDebug] = useState(false)
|
const [showDebug, setShowDebug] = useState(false)
|
||||||
const handleChangeShowDebug = useCallback((ev) => {
|
const handleChangeShowDebug = useCallback((ev) => {
|
||||||
@@ -549,39 +556,91 @@ function App({ wsEndpoint, role }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<StyledDataContainer isConnected={isConnected}>
|
<StyledDataContainer isConnected={isConnected}>
|
||||||
<div>
|
{gridCount && (
|
||||||
{range(0, gridCount).map((y) => (
|
<StyledGridContainer
|
||||||
<StyledGridLine>
|
windowWidth={windowWidth}
|
||||||
{range(0, gridCount).map((x) => {
|
windowHeight={windowHeight}
|
||||||
|
>
|
||||||
|
<StyledGridInputs>
|
||||||
|
{range(0, gridCount).map((y) =>
|
||||||
|
range(0, gridCount).map((x) => {
|
||||||
const idx = gridCount * y + x
|
const idx = gridCount * y + x
|
||||||
const {
|
const { state } = stateIdxMap.get(idx) || {}
|
||||||
isListening = false,
|
const { streamId } = sharedState.views?.[idx] ?? {}
|
||||||
isBackgroundListening = false,
|
|
||||||
isBlurred = false,
|
|
||||||
state,
|
|
||||||
} = stateIdxMap.get(idx) || {}
|
|
||||||
const { streamId } = sharedState.views?.[idx] || ''
|
|
||||||
const isDragHighlighted =
|
const isDragHighlighted =
|
||||||
dragStart !== undefined &&
|
dragStart !== undefined &&
|
||||||
idxInBox(gridCount, dragStart, dragEnd, idx)
|
idxInBox(gridCount, dragStart, dragEnd, idx)
|
||||||
return (
|
return (
|
||||||
<GridInput
|
<GridInput
|
||||||
|
style={{
|
||||||
|
width: `${100 / gridCount}%`,
|
||||||
|
height: `${100 / gridCount}%`,
|
||||||
|
left: `${(100 * x) / gridCount}%`,
|
||||||
|
top: `${(100 * y) / gridCount}%`,
|
||||||
|
}}
|
||||||
idx={idx}
|
idx={idx}
|
||||||
spaceValue={streamId}
|
spaceValue={streamId}
|
||||||
isError={state && state.matches('displaying.error')}
|
onChangeSpace={handleSetView}
|
||||||
isDisplaying={state && state.matches('displaying')}
|
|
||||||
isListening={isListening}
|
|
||||||
isBackgroundListening={isBackgroundListening}
|
|
||||||
isBlurred={isBlurred}
|
|
||||||
isHighlighted={isDragHighlighted}
|
isHighlighted={isDragHighlighted}
|
||||||
isSwapping={idx === swapStartIdx}
|
|
||||||
showDebug={showDebug}
|
|
||||||
role={role}
|
role={role}
|
||||||
onMouseDown={handleDragStart}
|
onMouseDown={handleDragStart}
|
||||||
onMouseEnter={setDragEnd}
|
onMouseEnter={setDragEnd}
|
||||||
onFocus={handleFocusInput}
|
onFocus={handleFocusInput}
|
||||||
onBlur={handleBlurInput}
|
onBlur={handleBlurInput}
|
||||||
onChangeSpace={handleSetView}
|
/>
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
</StyledGridInputs>
|
||||||
|
<StyledGridPreview>
|
||||||
|
{views.map(({ state, isListening }) => {
|
||||||
|
const { pos } = state.context
|
||||||
|
const { streamId } = sharedState.views[pos.spaces[0]] ?? {}
|
||||||
|
const data = streams.find((d) => d._id === streamId)
|
||||||
|
return (
|
||||||
|
<StyledGridPreviewBox
|
||||||
|
color={idColor(streamId)}
|
||||||
|
style={{
|
||||||
|
left: `${(100 * pos.x) / windowWidth}%`,
|
||||||
|
top: `${(100 * pos.y) / windowHeight}%`,
|
||||||
|
width: `${(100 * pos.width) / windowWidth}%`,
|
||||||
|
height: `${(100 * pos.height) / windowHeight}%`,
|
||||||
|
}}
|
||||||
|
pos={pos}
|
||||||
|
windowWidth={windowWidth}
|
||||||
|
windowHeight={windowHeight}
|
||||||
|
isListening={isListening}
|
||||||
|
isError={state && state.matches('displaying.error')}
|
||||||
|
>
|
||||||
|
<StyledGridInfo>
|
||||||
|
<StyledGridLabel>{streamId}</StyledGridLabel>
|
||||||
|
<div>{data?.source}</div>
|
||||||
|
</StyledGridInfo>
|
||||||
|
</StyledGridPreviewBox>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</StyledGridPreview>
|
||||||
|
{views.map(
|
||||||
|
({ state, isListening, isBackgroundListening, isBlurred }) => {
|
||||||
|
const { pos } = state.context
|
||||||
|
const { streamId } = sharedState.views[pos.spaces[0]] ?? {}
|
||||||
|
return (
|
||||||
|
<GridControls
|
||||||
|
idx={pos.spaces[0]}
|
||||||
|
streamId={streamId}
|
||||||
|
style={{
|
||||||
|
left: `${(100 * pos.x) / windowWidth}%`,
|
||||||
|
top: `${(100 * pos.y) / windowHeight}%`,
|
||||||
|
width: `${(100 * pos.width) / windowWidth}%`,
|
||||||
|
height: `${(100 * pos.height) / windowHeight}%`,
|
||||||
|
}}
|
||||||
|
isDisplaying={state && state.matches('displaying')}
|
||||||
|
isListening={isListening}
|
||||||
|
isBackgroundListening={isBackgroundListening}
|
||||||
|
isBlurred={isBlurred}
|
||||||
|
isSwapping={pos.spaces.includes(swapStartIdx)}
|
||||||
|
showDebug={showDebug}
|
||||||
|
role={role}
|
||||||
onSetListening={handleSetListening}
|
onSetListening={handleSetListening}
|
||||||
onSetBackgroundListening={handleSetBackgroundListening}
|
onSetBackgroundListening={handleSetBackgroundListening}
|
||||||
onSetBlurred={handleSetBlurred}
|
onSetBlurred={handleSetBlurred}
|
||||||
@@ -591,10 +650,10 @@ function App({ wsEndpoint, role }) {
|
|||||||
onDevTools={handleDevTools}
|
onDevTools={handleDevTools}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
},
|
||||||
</StyledGridLine>
|
)}
|
||||||
))}
|
</StyledGridContainer>
|
||||||
</div>
|
)}
|
||||||
{(roleCan(role, 'dev-tools') || roleCan(role, 'browse')) && (
|
{(roleCan(role, 'dev-tools') || roleCan(role, 'browse')) && (
|
||||||
<label>
|
<label>
|
||||||
<input
|
<input
|
||||||
@@ -790,29 +849,16 @@ function StreamLine({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function GridInput({
|
function GridInput({
|
||||||
|
style,
|
||||||
idx,
|
idx,
|
||||||
onChangeSpace,
|
onChangeSpace,
|
||||||
spaceValue,
|
spaceValue,
|
||||||
isDisplaying,
|
|
||||||
isError,
|
|
||||||
isListening,
|
|
||||||
isBackgroundListening,
|
|
||||||
isBlurred,
|
|
||||||
isHighlighted,
|
isHighlighted,
|
||||||
isSwapping,
|
|
||||||
showDebug,
|
|
||||||
role,
|
role,
|
||||||
onMouseDown,
|
onMouseDown,
|
||||||
onMouseEnter,
|
onMouseEnter,
|
||||||
onFocus,
|
onFocus,
|
||||||
onBlur,
|
onBlur,
|
||||||
onSetListening,
|
|
||||||
onSetBackgroundListening,
|
|
||||||
onSetBlurred,
|
|
||||||
onReloadView,
|
|
||||||
onSwapView,
|
|
||||||
onBrowse,
|
|
||||||
onDevTools,
|
|
||||||
}) {
|
}) {
|
||||||
const [editingValue, setEditingValue] = useState()
|
const [editingValue, setEditingValue] = useState()
|
||||||
const handleFocus = useCallback(
|
const handleFocus = useCallback(
|
||||||
@@ -837,6 +883,51 @@ function GridInput({
|
|||||||
},
|
},
|
||||||
[idx, onChangeSpace],
|
[idx, onChangeSpace],
|
||||||
)
|
)
|
||||||
|
const handleMouseDown = useCallback(
|
||||||
|
(ev) => {
|
||||||
|
onMouseDown(idx, ev)
|
||||||
|
},
|
||||||
|
[onMouseDown],
|
||||||
|
)
|
||||||
|
const handleMouseEnter = useCallback(() => onMouseEnter(idx), [onMouseEnter])
|
||||||
|
return (
|
||||||
|
<StyledGridInputContainer style={style}>
|
||||||
|
<StyledGridInput
|
||||||
|
value={editingValue || spaceValue || ''}
|
||||||
|
color={idColor(spaceValue)}
|
||||||
|
isHighlighted={isHighlighted}
|
||||||
|
disabled={!roleCan(role, 'mutate-state-doc')}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onMouseDown={handleMouseDown}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
</StyledGridInputContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GridControls({
|
||||||
|
idx,
|
||||||
|
streamId,
|
||||||
|
style,
|
||||||
|
isDisplaying,
|
||||||
|
isListening,
|
||||||
|
isBackgroundListening,
|
||||||
|
isBlurred,
|
||||||
|
isSwapping,
|
||||||
|
showDebug,
|
||||||
|
role,
|
||||||
|
onSetListening,
|
||||||
|
onSetBackgroundListening,
|
||||||
|
onSetBlurred,
|
||||||
|
onReloadView,
|
||||||
|
onSwapView,
|
||||||
|
onBrowse,
|
||||||
|
onDevTools,
|
||||||
|
}) {
|
||||||
|
// TODO: Refactor callbacks to use streamID instead of idx.
|
||||||
|
// We should probably also switch the view-state-changing RPCs to use a view id instead of idx like they do currently.
|
||||||
const handleListeningClick = useCallback(
|
const handleListeningClick = useCallback(
|
||||||
(ev) =>
|
(ev) =>
|
||||||
ev.shiftKey || isBackgroundListening
|
ev.shiftKey || isBackgroundListening
|
||||||
@@ -860,23 +951,16 @@ function GridInput({
|
|||||||
onReloadView,
|
onReloadView,
|
||||||
])
|
])
|
||||||
const handleSwapClick = useCallback(() => onSwapView(idx), [idx, onSwapView])
|
const handleSwapClick = useCallback(() => onSwapView(idx), [idx, onSwapView])
|
||||||
const handleBrowseClick = useCallback(() => onBrowse(spaceValue), [
|
const handleBrowseClick = useCallback(() => onBrowse(streamId), [
|
||||||
spaceValue,
|
streamId,
|
||||||
onBrowse,
|
onBrowse,
|
||||||
])
|
])
|
||||||
const handleDevToolsClick = useCallback(() => onDevTools(idx), [
|
const handleDevToolsClick = useCallback(() => onDevTools(idx), [
|
||||||
idx,
|
idx,
|
||||||
onDevTools,
|
onDevTools,
|
||||||
])
|
])
|
||||||
const handleMouseDown = useCallback(
|
|
||||||
(ev) => {
|
|
||||||
onMouseDown(idx, ev)
|
|
||||||
},
|
|
||||||
[onMouseDown],
|
|
||||||
)
|
|
||||||
const handleMouseEnter = useCallback(() => onMouseEnter(idx), [onMouseEnter])
|
|
||||||
return (
|
return (
|
||||||
<StyledGridContainer>
|
<StyledGridControlsContainer style={style}>
|
||||||
{isDisplaying && (
|
{isDisplaying && (
|
||||||
<StyledGridButtons side="left">
|
<StyledGridButtons side="left">
|
||||||
{showDebug ? (
|
{showDebug ? (
|
||||||
@@ -933,19 +1017,7 @@ function GridInput({
|
|||||||
</StyledButton>
|
</StyledButton>
|
||||||
)}
|
)}
|
||||||
</StyledGridButtons>
|
</StyledGridButtons>
|
||||||
<StyledGridInput
|
</StyledGridControlsContainer>
|
||||||
value={editingValue || spaceValue || ''}
|
|
||||||
color={idColor(spaceValue)}
|
|
||||||
isError={isError}
|
|
||||||
isHighlighted={isHighlighted}
|
|
||||||
disabled={!roleCan(role, 'mutate-state-doc')}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onBlur={handleBlur}
|
|
||||||
onMouseDown={handleMouseDown}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onChange={handleChange}
|
|
||||||
/>
|
|
||||||
</StyledGridContainer>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1021,10 +1093,6 @@ const StyledDataContainer = styled.div`
|
|||||||
opacity: ${({ isConnected }) => (isConnected ? 1 : 0.5)};
|
opacity: ${({ isConnected }) => (isConnected ? 1 : 0.5)};
|
||||||
`
|
`
|
||||||
|
|
||||||
const StyledGridLine = styled.div`
|
|
||||||
display: flex;
|
|
||||||
`
|
|
||||||
|
|
||||||
const StyledButton = styled.button`
|
const StyledButton = styled.button`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1058,15 +1126,67 @@ const StyledSmallButton = styled(StyledButton)`
|
|||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
const StyledGridContainer = styled.div`
|
const StyledGridPreview = styled.div`
|
||||||
position: relative;
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
`
|
||||||
|
|
||||||
|
const StyledGridPreviewBox = styled.div.attrs((props) => ({
|
||||||
|
borderWidth: 2,
|
||||||
|
}))`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: absolute;
|
||||||
|
background: ${({ color }) => color.lightness(50) || '#333'};
|
||||||
|
border: 0 solid ${({ isError }) => (isError ? 'red' : 'black')};
|
||||||
|
border-left-width: ${({ pos, borderWidth }) =>
|
||||||
|
pos.x === 0 ? 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, windowHeight }) =>
|
||||||
|
pos.y + pos.height === windowHeight ? 0 : borderWidth}px;
|
||||||
|
box-shadow: ${({ isListening }) =>
|
||||||
|
isListening ? `0 0 10px red inset` : 'none'};
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
`
|
||||||
|
|
||||||
|
const StyledGridInfo = styled.div`
|
||||||
|
text-align: center;
|
||||||
|
`
|
||||||
|
|
||||||
|
const StyledGridLabel = styled.div`
|
||||||
|
font-size: 30px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const StyledGridInputs = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 100ms ease-out;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 100;
|
||||||
|
`
|
||||||
|
|
||||||
|
const StyledGridInputContainer = styled.div`
|
||||||
|
position: absolute;
|
||||||
`
|
`
|
||||||
|
|
||||||
const StyledGridButtons = styled.div`
|
const StyledGridButtons = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 0;
|
${({ side }) =>
|
||||||
${({ side }) => (side === 'left' ? 'left: 0' : 'right: 0')};
|
side === 'left' ? 'top: 0; left: 0' : 'bottom: 0; right: 0'};
|
||||||
|
|
||||||
${StyledButton} {
|
${StyledButton} {
|
||||||
margin: 5px;
|
margin: 5px;
|
||||||
@@ -1075,18 +1195,43 @@ const StyledGridButtons = styled.div`
|
|||||||
`
|
`
|
||||||
|
|
||||||
const StyledGridInput = styled.input`
|
const StyledGridInput = styled.input`
|
||||||
width: 160px;
|
width: 100%;
|
||||||
height: 50px;
|
height: 100%;
|
||||||
padding: 20px;
|
outline: 1px solid black;
|
||||||
border: 2px solid ${({ isError }) => (isError ? 'red' : 'black')};
|
border: none;
|
||||||
|
padding: 0;
|
||||||
background: ${({ color, isHighlighted }) =>
|
background: ${({ color, isHighlighted }) =>
|
||||||
isHighlighted ? color.lightness(90) : color.lightness(75)};
|
isHighlighted ? color.lightness(90) : color.lightness(75)};
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: 1px solid black;
|
||||||
box-shadow: 0 0 5px orange inset;
|
box-shadow: 0 0 5px black inset;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const StyledGridControlsContainer = styled.div`
|
||||||
|
position: absolute;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const StyledGridContainer = styled.div.attrs((props) => ({
|
||||||
|
scale: 0.75,
|
||||||
|
}))`
|
||||||
|
position: relative;
|
||||||
|
width: ${({ windowWidth, scale }) => windowWidth * scale}px;
|
||||||
|
height: ${({ windowHeight, scale }) => windowHeight * scale}px;
|
||||||
|
border: 2px solid black;
|
||||||
|
background: black;
|
||||||
|
|
||||||
|
&:hover ${StyledGridInputs} {
|
||||||
|
opacity: 0.35;
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user