Initial release

This commit is contained in:
Max Goodhart
2020-06-14 22:50:49 -07:00
commit ba794aa117
30 changed files with 12625 additions and 0 deletions

13
src/node/.babelrc.json Normal file
View File

@@ -0,0 +1,13 @@
{
"presets": [
[
"@babel/preset-env",
{
"modules": "commonjs",
"targets": {
"node": true
}
}
]
]
}

54
src/node/geometry.js Normal file
View 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
View 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
View 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)
}

View 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