mirror of
https://github.com/streamwall/streamwall.git
synced 2026-01-25 22:52:48 -05:00
Initial release
This commit is contained in:
13
src/node/.babelrc.json
Normal file
13
src/node/.babelrc.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"presets": [
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
"modules": "commonjs",
|
||||
"targets": {
|
||||
"node": true
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
54
src/node/geometry.js
Normal file
54
src/node/geometry.js
Normal file
@@ -0,0 +1,54 @@
|
||||
export function boxesFromSpaceURLMap(width, height, stateURLMap) {
|
||||
const boxes = []
|
||||
const visited = new Set()
|
||||
|
||||
function findLargestBox(x, y) {
|
||||
const idx = width * y + x
|
||||
const spaces = [idx]
|
||||
const url = stateURLMap.get(idx)
|
||||
|
||||
let maxY
|
||||
for (maxY = y + 1; maxY < height; maxY++) {
|
||||
const checkIdx = width * maxY + x
|
||||
if (visited.has(checkIdx) || stateURLMap.get(checkIdx) !== url) {
|
||||
break
|
||||
}
|
||||
spaces.push(width * maxY + x)
|
||||
}
|
||||
|
||||
let cx = x
|
||||
let cy = y
|
||||
scan: for (cx = x + 1; cx < width; cx++) {
|
||||
for (cy = y; cy < maxY; cy++) {
|
||||
const checkIdx = width * cy + cx
|
||||
if (visited.has(checkIdx) || stateURLMap.get(checkIdx) !== url) {
|
||||
break scan
|
||||
}
|
||||
}
|
||||
for (let cy = y; cy < maxY; cy++) {
|
||||
spaces.push(width * cy + cx)
|
||||
}
|
||||
}
|
||||
const w = cx - x
|
||||
const h = maxY - y
|
||||
spaces.sort()
|
||||
return { url, x, y, w, h, spaces }
|
||||
}
|
||||
|
||||
for (let y = 0; y < width; y++) {
|
||||
for (let x = 0; x < height; x++) {
|
||||
const idx = width * y + x
|
||||
if (visited.has(idx) || stateURLMap.get(idx) === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
const box = findLargestBox(x, y)
|
||||
boxes.push(box)
|
||||
for (const boxIdx of box.spaces) {
|
||||
visited.add(boxIdx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return boxes
|
||||
}
|
||||
91
src/node/geometry.test.js
Normal file
91
src/node/geometry.test.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { boxesFromSpaceURLMap } from './geometry'
|
||||
|
||||
const box1 = `
|
||||
ab
|
||||
ab
|
||||
`
|
||||
.replace(/\s/g, '')
|
||||
.split('')
|
||||
|
||||
const box2 = `
|
||||
aa
|
||||
bb
|
||||
`
|
||||
.replace(/\s/g, '')
|
||||
.split('')
|
||||
|
||||
const box3 = `
|
||||
aac
|
||||
aaa
|
||||
dae
|
||||
`
|
||||
.replace(/\s/g, '')
|
||||
.split('')
|
||||
|
||||
const box4 = `
|
||||
...
|
||||
.aa
|
||||
.aa
|
||||
`
|
||||
.replace(/\s/g, '')
|
||||
.split('')
|
||||
.map((c) => (c === '.' ? undefined : c))
|
||||
|
||||
const box5 = `
|
||||
..a
|
||||
..a
|
||||
.aa
|
||||
`
|
||||
.replace(/\s/g, '')
|
||||
.split('')
|
||||
.map((c) => (c === '.' ? undefined : c))
|
||||
|
||||
describe.each([
|
||||
[
|
||||
2,
|
||||
2,
|
||||
box1,
|
||||
[
|
||||
{ url: 'a', x: 0, y: 0, w: 1, h: 2, spaces: [0, 2] },
|
||||
{ url: 'b', x: 1, y: 0, w: 1, h: 2, spaces: [1, 3] },
|
||||
],
|
||||
],
|
||||
[
|
||||
2,
|
||||
2,
|
||||
box2,
|
||||
[
|
||||
{ url: 'a', x: 0, y: 0, w: 2, h: 1, spaces: [0, 1] },
|
||||
{ url: 'b', x: 0, y: 1, w: 2, h: 1, spaces: [2, 3] },
|
||||
],
|
||||
],
|
||||
[
|
||||
3,
|
||||
3,
|
||||
box3,
|
||||
[
|
||||
{ url: 'a', x: 0, y: 0, w: 2, h: 2, spaces: [0, 1, 3, 4] },
|
||||
{ url: 'c', x: 2, y: 0, w: 1, h: 1, spaces: [2] },
|
||||
{ url: 'a', x: 2, y: 1, w: 1, h: 1, spaces: [5] },
|
||||
{ url: 'd', x: 0, y: 2, w: 1, h: 1, spaces: [6] },
|
||||
{ url: 'a', x: 1, y: 2, w: 1, h: 1, spaces: [7] },
|
||||
{ url: 'e', x: 2, y: 2, w: 1, h: 1, spaces: [8] },
|
||||
],
|
||||
],
|
||||
[3, 3, box4, [{ url: 'a', x: 1, y: 1, w: 2, h: 2, spaces: [4, 5, 7, 8] }]],
|
||||
[
|
||||
3,
|
||||
3,
|
||||
box5,
|
||||
[
|
||||
{ url: 'a', x: 2, y: 0, w: 1, h: 3, spaces: [2, 5, 8] },
|
||||
{ url: 'a', x: 1, y: 2, w: 1, h: 1, spaces: [7] },
|
||||
],
|
||||
],
|
||||
])('boxesFromSpaceURLMap(%i, %i, %j)', (width, height, data, expected) => {
|
||||
test(`returns expected ${expected.length} boxes`, () => {
|
||||
const stateURLMap = new Map(data.map((v, idx) => [idx, v]))
|
||||
const result = boxesFromSpaceURLMap(width, height, stateURLMap)
|
||||
expect(result).toStrictEqual(expected)
|
||||
})
|
||||
})
|
||||
176
src/node/index.js
Normal file
176
src/node/index.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import { app, BrowserWindow, BrowserView, ipcMain, shell } from 'electron'
|
||||
import { interpret } from 'xstate'
|
||||
import fetch from 'node-fetch'
|
||||
import csv from 'csvtojson'
|
||||
|
||||
import viewStateMachine from './viewStateMachine'
|
||||
import { boxesFromSpaceURLMap } from './geometry'
|
||||
|
||||
import {
|
||||
WIDTH,
|
||||
HEIGHT,
|
||||
GRID_COUNT,
|
||||
SPACE_WIDTH,
|
||||
SPACE_HEIGHT,
|
||||
DATA_URL,
|
||||
REFRESH_INTERVAL,
|
||||
} from '../constants'
|
||||
|
||||
async function fetchData() {
|
||||
// TODO: stable idxs
|
||||
const resp = await fetch(DATA_URL)
|
||||
const text = await resp.text()
|
||||
const data = await csv().fromString(text)
|
||||
return data.filter((d) => d.Link && d.Status === 'Live')
|
||||
}
|
||||
|
||||
function main() {
|
||||
const mainWin = new BrowserWindow({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 800,
|
||||
height: 600,
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
},
|
||||
})
|
||||
mainWin.loadFile('control.html')
|
||||
mainWin.webContents.on('will-navigate', (ev, url) => {
|
||||
ev.preventDefault()
|
||||
shell.openExternal(url)
|
||||
})
|
||||
|
||||
const streamWin = new BrowserWindow({
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
backgroundColor: '#000',
|
||||
useContentSize: true,
|
||||
show: false,
|
||||
})
|
||||
streamWin.removeMenu()
|
||||
streamWin.loadURL('about:blank')
|
||||
|
||||
// Work around https://github.com/electron/electron/issues/14308
|
||||
// via https://github.com/lutzroeder/netron/commit/910ce67395130690ad76382c094999a4f5b51e92
|
||||
streamWin.once('ready-to-show', () => {
|
||||
streamWin.resizable = false
|
||||
streamWin.show()
|
||||
})
|
||||
|
||||
const overlayView = new BrowserView({
|
||||
webPreferences: {
|
||||
nodeIntegration: true,
|
||||
},
|
||||
})
|
||||
streamWin.addBrowserView(overlayView)
|
||||
overlayView.setBounds({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: WIDTH,
|
||||
height: HEIGHT,
|
||||
})
|
||||
overlayView.webContents.loadFile('overlay.html')
|
||||
|
||||
const actions = {
|
||||
hideView: (context, event) => {
|
||||
const { view } = context
|
||||
streamWin.removeBrowserView(view)
|
||||
},
|
||||
positionView: (context, event) => {
|
||||
const { pos, view } = context
|
||||
streamWin.addBrowserView(view)
|
||||
|
||||
// It's necessary to remove and re-add the overlay view to ensure it's on top.
|
||||
streamWin.removeBrowserView(overlayView)
|
||||
streamWin.addBrowserView(overlayView)
|
||||
|
||||
view.setBounds(pos)
|
||||
},
|
||||
}
|
||||
|
||||
const views = []
|
||||
for (let idx = 0; idx <= 9; idx++) {
|
||||
const view = new BrowserView()
|
||||
view.setBackgroundColor('#000')
|
||||
|
||||
const machine = viewStateMachine
|
||||
.withContext({
|
||||
...viewStateMachine.context,
|
||||
view,
|
||||
parentWin: streamWin,
|
||||
overlayView,
|
||||
})
|
||||
.withConfig({ actions })
|
||||
const service = interpret(machine).start()
|
||||
service.onTransition((state) => {
|
||||
overlayView.webContents.send('space-state', idx, {
|
||||
state: state.value,
|
||||
context: {
|
||||
url: state.context.url,
|
||||
info: state.context.info,
|
||||
bounds: state.context.pos,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
views.push(service)
|
||||
}
|
||||
|
||||
ipcMain.on('set-videos', async (ev, spaceURLMap) => {
|
||||
const boxes = boxesFromSpaceURLMap(GRID_COUNT, GRID_COUNT, spaceURLMap)
|
||||
|
||||
const unusedViews = new Set(views)
|
||||
for (const box of boxes) {
|
||||
const { url, x, y, w, h, spaces } = box
|
||||
// TODO: prefer fully loaded views
|
||||
let space = views.find(
|
||||
(s) => unusedViews.has(s) && s.state.context.url === url,
|
||||
)
|
||||
if (!space) {
|
||||
space = views.find(
|
||||
(s) => unusedViews.has(s) && !s.state.matches('displaying'),
|
||||
)
|
||||
}
|
||||
const pos = {
|
||||
x: SPACE_WIDTH * x,
|
||||
y: SPACE_HEIGHT * y,
|
||||
width: SPACE_WIDTH * w,
|
||||
height: SPACE_HEIGHT * h,
|
||||
spaces,
|
||||
}
|
||||
space.send({ type: 'DISPLAY', pos, url })
|
||||
unusedViews.delete(space)
|
||||
}
|
||||
|
||||
for (const space of unusedViews) {
|
||||
space.send('CLEAR')
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on('set-sound-source', async (ev, spaceIdx) => {
|
||||
for (const view of views) {
|
||||
if (!view.state.matches('displaying')) {
|
||||
continue
|
||||
}
|
||||
const { context } = view.state
|
||||
const isSelectedView = context.pos.spaces.includes(spaceIdx)
|
||||
view.send(isSelectedView ? 'UNMUTE' : 'MUTE')
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.on('devtools-overlay', () => {
|
||||
overlayView.webContents.openDevTools()
|
||||
})
|
||||
|
||||
async function refreshData() {
|
||||
const data = await fetchData()
|
||||
mainWin.webContents.send('stream-data', data)
|
||||
overlayView.webContents.send('stream-data', data)
|
||||
}
|
||||
setInterval(refreshData, REFRESH_INTERVAL)
|
||||
refreshData()
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
app.whenReady().then(main)
|
||||
}
|
||||
201
src/node/viewStateMachine.js
Normal file
201
src/node/viewStateMachine.js
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Machine, assign } from 'xstate'
|
||||
|
||||
const viewStateMachine = Machine(
|
||||
{
|
||||
id: 'view',
|
||||
initial: 'empty',
|
||||
context: {
|
||||
view: null,
|
||||
pos: null,
|
||||
url: null,
|
||||
info: {},
|
||||
},
|
||||
on: {
|
||||
CLEAR: 'empty',
|
||||
DISPLAY: 'displaying',
|
||||
},
|
||||
states: {
|
||||
empty: {
|
||||
entry: [
|
||||
assign({
|
||||
pos: { url: null },
|
||||
info: {},
|
||||
}),
|
||||
'hideView',
|
||||
],
|
||||
invoke: {
|
||||
src: 'clearView',
|
||||
onError: {
|
||||
target: '#view.error',
|
||||
},
|
||||
},
|
||||
},
|
||||
displaying: {
|
||||
id: 'displaying',
|
||||
initial: 'loading',
|
||||
entry: assign({
|
||||
pos: (context, event) => event.pos,
|
||||
url: (context, event) => event.url,
|
||||
}),
|
||||
on: {
|
||||
DISPLAY: {
|
||||
actions: assign({
|
||||
pos: (context, event) => event.pos,
|
||||
}),
|
||||
cond: 'urlUnchanged',
|
||||
},
|
||||
},
|
||||
states: {
|
||||
loading: {
|
||||
initial: 'page',
|
||||
states: {
|
||||
page: {
|
||||
invoke: {
|
||||
src: 'loadURL',
|
||||
onDone: {
|
||||
target: 'video',
|
||||
},
|
||||
onError: {
|
||||
target: '#view.error',
|
||||
},
|
||||
},
|
||||
},
|
||||
video: {
|
||||
invoke: {
|
||||
src: 'startVideo',
|
||||
onDone: {
|
||||
target: '#view.displaying.running',
|
||||
actions: assign({
|
||||
info: (context, event) => event.data,
|
||||
}),
|
||||
},
|
||||
onError: {
|
||||
target: '#view.error',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
running: {
|
||||
initial: 'muted',
|
||||
entry: 'positionView',
|
||||
on: {
|
||||
DISPLAY: {
|
||||
actions: [
|
||||
assign({
|
||||
pos: (context, event) => event.pos,
|
||||
}),
|
||||
'positionView',
|
||||
],
|
||||
cond: 'urlUnchanged',
|
||||
},
|
||||
MUTE: '.muted',
|
||||
UNMUTE: '.listening',
|
||||
},
|
||||
states: {
|
||||
muted: {
|
||||
entry: 'muteAudio',
|
||||
},
|
||||
listening: {
|
||||
entry: 'unmuteAudio',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
error: {
|
||||
entry: 'logError',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
actions: {
|
||||
logError: (context, event) => {
|
||||
console.warn(event)
|
||||
},
|
||||
muteAudio: (context, event) => {
|
||||
context.view.webContents.audioMuted = true
|
||||
},
|
||||
unmuteAudio: (context, event) => {
|
||||
context.view.webContents.audioMuted = false
|
||||
},
|
||||
},
|
||||
guards: {
|
||||
urlUnchanged: (context, event) => {
|
||||
return context.url === event.url
|
||||
},
|
||||
},
|
||||
services: {
|
||||
clearView: async (context, event) => {
|
||||
await context.view.webContents.loadURL('about:blank')
|
||||
},
|
||||
loadURL: async (context, event) => {
|
||||
const { url, view } = context
|
||||
const wc = view.webContents
|
||||
wc.audioMuted = true
|
||||
await wc.loadURL(url)
|
||||
wc.insertCSS(
|
||||
`
|
||||
* {
|
||||
display: none !important;
|
||||
pointer-events: none;
|
||||
}
|
||||
html, body, video {
|
||||
display: block !important;
|
||||
background: black !important;
|
||||
}
|
||||
html, body {
|
||||
overflow: hidden !important;
|
||||
background: black !important;
|
||||
}
|
||||
video {
|
||||
display: block !important;
|
||||
position: fixed !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
top: 0 !important;
|
||||
bottom: 0 !important;
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
object-fit: cover !important;
|
||||
z-index: 999999 !important;
|
||||
}
|
||||
`,
|
||||
{ cssOrigin: 'user' },
|
||||
)
|
||||
},
|
||||
startVideo: async (context, event) => {
|
||||
const wc = context.view.webContents
|
||||
const info = await wc.executeJavaScript(`
|
||||
const sleep = ms => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
async function waitForVideo() {
|
||||
// Give the client side a little time to load. In particular, YouTube seems to need a delay.
|
||||
await sleep(1000)
|
||||
|
||||
let tries = 0
|
||||
let video
|
||||
while (!video && tries < 20) {
|
||||
video = document.querySelector('video')
|
||||
tries++
|
||||
await sleep(200)
|
||||
}
|
||||
if (!video) {
|
||||
throw new Error('could not find video')
|
||||
}
|
||||
document.body.appendChild(video)
|
||||
video.muted = false
|
||||
video.autoPlay = true
|
||||
video.play()
|
||||
setInterval(() => video.play(), 1000)
|
||||
const info = {title: document.title}
|
||||
return info
|
||||
}
|
||||
waitForVideo()
|
||||
`)
|
||||
return info
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export default viewStateMachine
|
||||
Reference in New Issue
Block a user