import range from 'lodash/range'
import sortBy from 'lodash/sortBy'
import truncate from 'lodash/truncate'
import ReconnectingWebSocket from 'reconnecting-websocket'
import * as Y from 'yjs'
import { patch as patchJSON } from 'jsondiffpatch'
import { h, Fragment, render } from 'preact'
import {
useEffect,
useLayoutEffect,
useState,
useCallback,
useRef,
} from 'preact/hooks'
import { State } from 'xstate'
import styled, { createGlobalStyle } from 'styled-components'
import { useHotkeys } from 'react-hotkeys-hook'
import Color from 'color'
import { DateTime } from 'luxon'
import '../index.css'
import { idxInBox } from '../geometry'
import { roleCan } from '../roles'
import SoundIcon from '../static/volume-up-solid.svg'
import NoVideoIcon from '../static/video-slash-solid.svg'
import ReloadIcon from '../static/sync-alt-solid.svg'
import RotateIcon from '../static/redo-alt-solid.svg'
import SwapIcon from '../static/exchange-alt-solid.svg'
import LifeRingIcon from '../static/life-ring-regular.svg'
import WindowIcon from '../static/window-maximize-regular.svg'
import { idColor } from './colors'
const hotkeyTriggers = [
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'9',
'0',
'q',
'w',
'e',
'r',
't',
'y',
'u',
'i',
'o',
'p',
]
const GlobalStyle = createGlobalStyle`
html {
height: 100%;
}
html, body {
display: flex;
flex: 1;
}
`
const normalStreamKinds = new Set(['video', 'audio', 'web'])
function filterStreams(streams) {
const liveStreams = []
const otherStreams = []
for (const stream of streams) {
const { kind, status } = stream
if (kind && !normalStreamKinds.has(kind)) {
continue
}
if ((kind && kind !== 'video') || status === 'Live') {
liveStreams.push(stream)
} else {
otherStreams.push(stream)
}
}
return [liveStreams, otherStreams]
}
function useYDoc(keys) {
const [doc, setDoc] = useState(new Y.Doc())
const [docValue, setDocValue] = useState()
useEffect(() => {
function updateDocValue() {
const valueCopy = Object.fromEntries(
keys.map((k) => [k, doc.getMap(k).toJSON()]),
)
setDocValue(valueCopy)
}
updateDocValue()
doc.on('update', updateDocValue)
return () => {
doc.off('update', updateDocValue)
}
}, [doc])
return [docValue, doc, setDoc]
}
function useStreamwallConnection(wsEndpoint) {
const wsRef = useRef()
const [isConnected, setIsConnected] = useState(false)
const [sharedState, stateDoc, setStateDoc] = useYDoc(['views'])
const [config, setConfig] = useState({})
const [streams, setStreams] = useState([])
const [customStreams, setCustomStreams] = useState([])
const [views, setViews] = useState([])
const [stateIdxMap, setStateIdxMap] = useState(new Map())
const [delayState, setDelayState] = useState()
const [authState, setAuthState] = useState()
useEffect(() => {
let lastStateData
const ws = new ReconnectingWebSocket(wsEndpoint, [], {
maxReconnectionDelay: 5000,
minReconnectionDelay: 1000 + Math.random() * 500,
reconnectionDelayGrowFactor: 1.1,
})
ws.binaryType = 'arraybuffer'
ws.addEventListener('open', () => setIsConnected(true))
ws.addEventListener('close', () => {
setStateDoc(new Y.Doc())
setIsConnected(false)
})
ws.addEventListener('message', (ev) => {
if (ev.data instanceof ArrayBuffer) {
return
}
const msg = JSON.parse(ev.data)
if (msg.response) {
const { responseMap } = wsRef.current
const responseCb = responseMap.get(msg.id)
if (responseCb) {
responseMap.delete(msg.id)
responseCb(msg)
}
} else if (msg.type === 'state' || msg.type === 'state-delta') {
let state
if (msg.type === 'state') {
state = msg.state
} else {
state = patchJSON(lastStateData, msg.delta)
}
lastStateData = state
const {
config: newConfig,
streams: newStreams,
views,
streamdelay,
auth,
} = state
const newStateIdxMap = new Map()
const newViews = []
for (const viewState of views) {
const { pos } = viewState.context
const state = State.from(viewState.state, viewState.context)
const isListening = state.matches(
'displaying.running.audio.listening',
)
const isBackgroundListening = state.matches(
'displaying.running.audio.background',
)
const isBlurred = state.matches('displaying.running.video.blurred')
const viewInfo = {
state,
isListening,
isBackgroundListening,
isBlurred,
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)
setStateIdxMap(newStateIdxMap)
setStreams(sortBy(newStreams, ['_id']))
setViews(newViews)
setCustomStreams(newStreams.filter((s) => s._dataSource === 'custom'))
setDelayState(
streamdelay && {
...streamdelay,
state: State.from(streamdelay.state),
},
)
setAuthState(auth)
} else {
console.warn('unexpected ws message', msg)
}
})
wsRef.current = { ws, msgId: 0, responseMap: new Map() }
}, [])
const send = useCallback((msg, cb) => {
const { ws, msgId, responseMap } = wsRef.current
ws.send(
JSON.stringify({
...msg,
id: msgId,
}),
)
if (cb) {
responseMap.set(msgId, cb)
}
wsRef.current.msgId++
}, [])
useEffect(() => {
function sendUpdate(update, origin) {
if (origin === 'server') {
return
}
wsRef.current.ws.send(update)
}
function receiveUpdate(ev) {
if (!(ev.data instanceof ArrayBuffer)) {
return
}
Y.applyUpdate(stateDoc, new Uint8Array(ev.data), 'server')
}
stateDoc.on('update', sendUpdate)
wsRef.current.ws.addEventListener('message', receiveUpdate)
return () => {
stateDoc.off('update', sendUpdate)
wsRef.current.ws.removeEventListener('message', receiveUpdate)
}
}, [stateDoc])
return {
isConnected,
send,
sharedState,
stateDoc,
config,
streams,
customStreams,
views,
stateIdxMap,
delayState,
authState,
}
}
function App({ wsEndpoint, role }) {
const {
isConnected,
send,
sharedState,
stateDoc,
config,
streams,
customStreams,
views,
stateIdxMap,
delayState,
authState,
} = useStreamwallConnection(wsEndpoint)
const { gridCount, width: windowWidth, height: windowHeight } = config
const [showDebug, setShowDebug] = useState(false)
const handleChangeShowDebug = useCallback((ev) => {
setShowDebug(ev.target.checked)
})
const [swapStartIdx, setSwapStartIdx] = useState()
const handleSwapView = useCallback(
(idx) => {
if (!stateIdxMap.has(idx)) {
return
}
// Deselect the input so the contents aren't persisted by GridInput's `editingValue`
document.activeElement.blur()
setSwapStartIdx(idx)
},
[stateIdxMap],
)
const handleSwap = useCallback(
(toIdx) => {
if (swapStartIdx === undefined) {
return
}
stateDoc.transact(() => {
const viewsState = stateDoc.getMap('views')
const startStreamId = viewsState
.get(String(swapStartIdx))
.get('streamId')
const toStreamId = viewsState.get(String(toIdx)).get('streamId')
const startSpaces = stateIdxMap.get(swapStartIdx).spaces
const toSpaces = stateIdxMap.get(toIdx).spaces
for (const startSpaceIdx of startSpaces) {
viewsState.get(String(startSpaceIdx)).set('streamId', toStreamId)
}
for (const toSpaceIdx of toSpaces) {
viewsState.get(String(toSpaceIdx)).set('streamId', startStreamId)
}
})
setSwapStartIdx()
},
[stateDoc, stateIdxMap, swapStartIdx],
)
const [hoveringIdx, setHoveringIdx] = useState()
const updateHoveringIdx = useCallback(
(ev) => {
const {
width,
height,
left,
top,
} = ev.currentTarget.getBoundingClientRect()
const x = Math.floor(ev.clientX - left)
const y = Math.floor(ev.clientY - top)
const spaceWidth = width / gridCount
const spaceHeight = height / gridCount
const idx =
Math.floor(y / spaceHeight) * gridCount + Math.floor(x / spaceWidth)
setHoveringIdx(idx)
},
[setHoveringIdx, gridCount],
)
const [dragStart, setDragStart] = useState()
const handleDragStart = useCallback(
(ev) => {
ev.preventDefault()
if (swapStartIdx !== undefined) {
handleSwap(hoveringIdx)
} else {
setDragStart(hoveringIdx)
// Select the text (if it is an input element)
ev.target.select?.()
}
},
[handleSwap, swapStartIdx, hoveringIdx],
)
useLayoutEffect(() => {
function endDrag() {
if (dragStart === undefined) {
return
}
stateDoc.transact(() => {
const viewsState = stateDoc.getMap('views')
const streamId = viewsState.get(String(dragStart)).get('streamId')
for (let idx = 0; idx < gridCount ** 2; idx++) {
if (idxInBox(gridCount, dragStart, hoveringIdx, idx)) {
viewsState.get(String(idx)).set('streamId', streamId)
}
}
})
setDragStart()
}
window.addEventListener('mouseup', endDrag)
return () => window.removeEventListener('mouseup', endDrag)
}, [stateDoc, dragStart, hoveringIdx])
const [focusedInputIdx, setFocusedInputIdx] = useState()
const handleFocusInput = useCallback(setFocusedInputIdx, [])
const handleBlurInput = useCallback(() => setFocusedInputIdx(), [])
const handleSetView = useCallback(
(idx, streamId) => {
const stream = streams.find((d) => d._id === streamId)
stateDoc
.getMap('views')
.get(String(idx))
.set('streamId', stream ? streamId : '')
},
[stateDoc, streams],
)
const handleSetListening = useCallback((idx, listening) => {
send({
type: 'set-listening-view',
viewIdx: listening ? idx : null,
})
}, [])
const handleSetBackgroundListening = useCallback((viewIdx, listening) => {
send({
type: 'set-view-background-listening',
viewIdx,
listening,
})
}, [])
const handleSetBlurred = useCallback((viewIdx, blurred) => {
send({
type: 'set-view-blurred',
viewIdx,
blurred: blurred,
})
}, [])
const handleReloadView = useCallback((viewIdx) => {
send({
type: 'reload-view',
viewIdx,
})
}, [])
const handleRotateStream = useCallback(
(streamId) => {
const stream = streams.find((d) => d._id === streamId)
if (!stream) {
return
}
send({
type: 'rotate-stream',
url: stream.link,
rotation: ((stream.rotation || 0) + 90) % 360,
})
},
[streams],
)
const handleBrowse = useCallback(
(streamId) => {
const stream = streams.find((d) => d._id === streamId)
if (!stream) {
return
}
send({
type: 'browse',
url: stream.link,
})
},
[streams],
)
const handleDevTools = useCallback((viewIdx) => {
send({
type: 'dev-tools',
viewIdx,
})
}, [])
const handleClickId = useCallback(
(streamId) => {
try {
navigator.clipboard.writeText(streamId)
} catch (err) {
console.warn('Unable to copy stream id to clipboard:', err)
}
if (focusedInputIdx !== undefined) {
handleSetView(focusedInputIdx, streamId)
return
}
const availableIdx = range(gridCount * gridCount).find(
(i) => !sharedState.views[i].streamId,
)
if (availableIdx === undefined) {
return
}
handleSetView(availableIdx, streamId)
},
[gridCount, sharedState, focusedInputIdx],
)
const handleChangeCustomStream = useCallback((url, customStream) => {
send({
type: 'update-custom-stream',
url,
data: customStream,
})
})
const handleDeleteCustomStream = useCallback((url) => {
send({
type: 'delete-custom-stream',
url,
})
return
})
const setStreamCensored = useCallback((isCensored) => {
send({
type: 'set-stream-censored',
isCensored,
})
}, [])
const setStreamRunning = useCallback((isStreamRunning) => {
send({
type: 'set-stream-running',
isStreamRunning,
})
}, [])
const [newInvite, setNewInvite] = useState()
const handleCreateInvite = useCallback(({ name, role }) => {
send(
{
type: 'create-invite',
name,
role,
},
({ name, secret }) => {
setNewInvite({ name, secret })
},
)
}, [])
const handleDeleteToken = useCallback((tokenId) => {
send({
type: 'delete-token',
tokenId,
})
}, [])
const preventLinkClick = useCallback((ev) => {
ev.preventDefault()
})
// Set up keyboard shortcuts.
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)
},
// This enables hotkeys when input elements are focused, and affects all hotkeys, not just this one.
{ filter: () => true },
[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`,
() => {
setStreamCensored(true)
},
[setStreamCensored],
)
useHotkeys(
`alt+shift+c`,
() => {
setStreamCensored(false)
},
[setStreamCensored],
)
useHotkeys(
`alt+s`,
() => {
handleSwapView(focusedInputIdx)
},
[handleSwapView, focusedInputIdx],
)
const [liveStreams, otherStreams] = filterStreams(streams)
function StreamList({ rows }) {
return rows.map((row) => (
Streamwall ({location.host})
Live
Offline / Unknown
Custom Streams
Access
Invites
{newInvite && (
Sessions
{authState.sessions.map(({ id, name, role }) => (