Add web-based remote control with HTTPS support
726
package-lock.json
generated
13
package.json
@@ -4,19 +4,30 @@
|
|||||||
"description": "View streams in a grid",
|
"description": "View streams in a grid",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "webpack --display=errors-only && electron dist",
|
"build": "webpack",
|
||||||
|
"start": "npm run build -- --display=errors-only && electron dist",
|
||||||
|
"start:prod": "NODE_ENV=production npm start",
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"author": "Max Goodhart <c@chromakode.com>",
|
"author": "Max Goodhart <c@chromakode.com>",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csvtojson": "^2.0.10",
|
"csvtojson": "^2.0.10",
|
||||||
|
"ejs": "^3.1.3",
|
||||||
"electron": "^9.0.4",
|
"electron": "^9.0.4",
|
||||||
"google-spreadsheet": "^3.0.11",
|
"google-spreadsheet": "^3.0.11",
|
||||||
|
"koa": "^2.12.1",
|
||||||
|
"koa-basic-auth": "^4.0.0",
|
||||||
|
"koa-easy-ws": "^1.1.3",
|
||||||
|
"koa-route": "^3.2.0",
|
||||||
|
"koa-static": "^5.0.0",
|
||||||
|
"koa-views": "^6.3.0",
|
||||||
"lodash": "^4.17.15",
|
"lodash": "^4.17.15",
|
||||||
"mousetrap": "^1.6.5",
|
"mousetrap": "^1.6.5",
|
||||||
"node-fetch": "^2.6.0",
|
"node-fetch": "^2.6.0",
|
||||||
|
"node-simple-cert": "0.0.1",
|
||||||
"preact": "^10.4.4",
|
"preact": "^10.4.4",
|
||||||
|
"reconnecting-websocket": "^4.4.0",
|
||||||
"styled-components": "^5.1.1",
|
"styled-components": "^5.1.1",
|
||||||
"svg-loaders-react": "^2.2.1",
|
"svg-loaders-react": "^2.2.1",
|
||||||
"xstate": "^4.10.0",
|
"xstate": "^4.10.0",
|
||||||
|
|||||||
@@ -6,15 +6,15 @@ import styled from 'styled-components'
|
|||||||
import Mousetrap from 'mousetrap'
|
import Mousetrap from 'mousetrap'
|
||||||
import { TailSpin } from 'svg-loaders-react'
|
import { TailSpin } from 'svg-loaders-react'
|
||||||
|
|
||||||
import './index.css'
|
import '../index.css'
|
||||||
import { WIDTH, HEIGHT } from '../constants'
|
import { WIDTH, HEIGHT } from '../constants'
|
||||||
|
|
||||||
import InstagramIcon from './static/instagram.svg'
|
import InstagramIcon from '../static/instagram.svg'
|
||||||
import FacebookIcon from './static/facebook.svg'
|
import FacebookIcon from '../static/facebook.svg'
|
||||||
import PeriscopeIcon from './static/periscope.svg'
|
import PeriscopeIcon from '../static/periscope.svg'
|
||||||
import TwitchIcon from './static/twitch.svg'
|
import TwitchIcon from '../static/twitch.svg'
|
||||||
import YouTubeIcon from './static/youtube.svg'
|
import YouTubeIcon from '../static/youtube.svg'
|
||||||
import SoundIcon from './static/volume-up-solid.svg'
|
import SoundIcon from '../static/volume-up-solid.svg'
|
||||||
|
|
||||||
Mousetrap.bind('ctrl+shift+i', () => {
|
Mousetrap.bind('ctrl+shift+i', () => {
|
||||||
ipcRenderer.send('devtools-overlay')
|
ipcRenderer.send('devtools-overlay')
|
||||||
@@ -25,12 +25,12 @@ function Overlay({ spaces, streamData }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{activeSpaces.map((spaceState) => {
|
{activeSpaces.map((spaceState) => {
|
||||||
const { url, bounds } = spaceState.context
|
const { url, pos } = spaceState.context
|
||||||
const data = streamData.find((d) => url === d.Link)
|
const data = streamData.find((d) => url === d.Link)
|
||||||
const isListening = spaceState.matches('displaying.running.listening')
|
const isListening = spaceState.matches('displaying.running.listening')
|
||||||
const isLoading = spaceState.matches('displaying.loading')
|
const isLoading = spaceState.matches('displaying.loading')
|
||||||
return (
|
return (
|
||||||
<SpaceBorder bounds={bounds} isListening={isListening}>
|
<SpaceBorder pos={pos} isListening={isListening}>
|
||||||
{data && (
|
{data && (
|
||||||
<>
|
<>
|
||||||
<StreamTitle isListening={isListening}>
|
<StreamTitle isListening={isListening}>
|
||||||
@@ -53,10 +53,10 @@ function App() {
|
|||||||
const [streamData, setStreamData] = useState([])
|
const [streamData, setStreamData] = useState([])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const spaceStateMap = new Map()
|
ipcRenderer.on('view-states', (ev, viewStates) => {
|
||||||
ipcRenderer.on('space-state', (ev, idx, { state, context }) => {
|
setSpaces(
|
||||||
spaceStateMap.set(idx, State.from(state, context))
|
viewStates.map(({ state, context }) => State.from(state, context)),
|
||||||
setSpaces([...spaceStateMap.values()])
|
)
|
||||||
})
|
})
|
||||||
ipcRenderer.on('stream-data', (ev, data) => {
|
ipcRenderer.on('stream-data', (ev, data) => {
|
||||||
setStreamData(data)
|
setStreamData(data)
|
||||||
@@ -94,19 +94,19 @@ const SpaceBorder = styled.div.attrs((props) => ({
|
|||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
}))`
|
}))`
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: ${({ bounds }) => bounds.x}px;
|
left: ${({ pos }) => pos.x}px;
|
||||||
top: ${({ bounds }) => bounds.y}px;
|
top: ${({ pos }) => pos.y}px;
|
||||||
width: ${({ bounds }) => bounds.width}px;
|
width: ${({ pos }) => pos.width}px;
|
||||||
height: ${({ bounds }) => bounds.height}px;
|
height: ${({ pos }) => pos.height}px;
|
||||||
border: 0 solid black;
|
border: 0 solid black;
|
||||||
border-left-width: ${({ bounds, borderWidth }) =>
|
border-left-width: ${({ pos, borderWidth }) =>
|
||||||
bounds.x === 0 ? 0 : borderWidth}px;
|
pos.x === 0 ? 0 : borderWidth}px;
|
||||||
border-right-width: ${({ bounds, borderWidth }) =>
|
border-right-width: ${({ pos, borderWidth }) =>
|
||||||
bounds.x + bounds.width === WIDTH ? 0 : borderWidth}px;
|
pos.x + pos.width === WIDTH ? 0 : borderWidth}px;
|
||||||
border-top-width: ${({ bounds, borderWidth }) =>
|
border-top-width: ${({ pos, borderWidth }) =>
|
||||||
bounds.y === 0 ? 0 : borderWidth}px;
|
pos.y === 0 ? 0 : borderWidth}px;
|
||||||
border-bottom-width: ${({ bounds, borderWidth }) =>
|
border-bottom-width: ${({ pos, borderWidth }) =>
|
||||||
bounds.y + bounds.height === HEIGHT ? 0 : borderWidth}px;
|
pos.y + pos.height === HEIGHT ? 0 : borderWidth}px;
|
||||||
box-shadow: ${({ isListening }) =>
|
box-shadow: ${({ isListening }) =>
|
||||||
isListening ? '0 0 10px red inset' : 'none'};
|
isListening ? '0 0 10px red inset' : 'none'};
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
166
src/node/StreamWindow.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import EventEmitter from 'events'
|
||||||
|
import { BrowserView, BrowserWindow, ipcMain } from 'electron'
|
||||||
|
import { interpret } from 'xstate'
|
||||||
|
|
||||||
|
import viewStateMachine from './viewStateMachine'
|
||||||
|
import { boxesFromViewURLMap } from './geometry'
|
||||||
|
|
||||||
|
import {
|
||||||
|
WIDTH,
|
||||||
|
HEIGHT,
|
||||||
|
GRID_COUNT,
|
||||||
|
SPACE_WIDTH,
|
||||||
|
SPACE_HEIGHT,
|
||||||
|
} from '../constants'
|
||||||
|
|
||||||
|
export default class StreamWindow extends EventEmitter {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.win = null
|
||||||
|
this.overlayView = null
|
||||||
|
this.views = []
|
||||||
|
this.viewStates = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
const win = new BrowserWindow({
|
||||||
|
width: WIDTH,
|
||||||
|
height: HEIGHT,
|
||||||
|
backgroundColor: '#000',
|
||||||
|
useContentSize: true,
|
||||||
|
show: false,
|
||||||
|
})
|
||||||
|
win.removeMenu()
|
||||||
|
win.loadURL('about:blank')
|
||||||
|
|
||||||
|
// Work around https://github.com/electron/electron/issues/14308
|
||||||
|
// via https://github.com/lutzroeder/netron/commit/910ce67395130690ad76382c094999a4f5b51e92
|
||||||
|
win.once('ready-to-show', () => {
|
||||||
|
win.resizable = false
|
||||||
|
win.show()
|
||||||
|
})
|
||||||
|
this.win = win
|
||||||
|
|
||||||
|
const overlayView = new BrowserView({
|
||||||
|
webPreferences: {
|
||||||
|
nodeIntegration: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
win.addBrowserView(overlayView)
|
||||||
|
overlayView.setBounds({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: WIDTH,
|
||||||
|
height: HEIGHT,
|
||||||
|
})
|
||||||
|
overlayView.webContents.loadFile('overlay.html')
|
||||||
|
this.overlayView = overlayView
|
||||||
|
|
||||||
|
const actions = {
|
||||||
|
hideView: (context, event) => {
|
||||||
|
const { view } = context
|
||||||
|
win.removeBrowserView(view)
|
||||||
|
},
|
||||||
|
positionView: (context, event) => {
|
||||||
|
const { pos, view } = context
|
||||||
|
win.addBrowserView(view)
|
||||||
|
|
||||||
|
// It's necessary to remove and re-add the overlay view to ensure it's on top.
|
||||||
|
win.removeBrowserView(overlayView)
|
||||||
|
win.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: win,
|
||||||
|
overlayView,
|
||||||
|
})
|
||||||
|
.withConfig({ actions })
|
||||||
|
const service = interpret(machine).start()
|
||||||
|
service.onTransition((state) => this.handleViewTransition(idx, state))
|
||||||
|
|
||||||
|
views.push(service)
|
||||||
|
}
|
||||||
|
this.views = views
|
||||||
|
|
||||||
|
ipcMain.on('devtools-overlay', () => {
|
||||||
|
overlayView.webContents.openDevTools()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleViewTransition(idx, state) {
|
||||||
|
const viewState = {
|
||||||
|
state: state.value,
|
||||||
|
context: {
|
||||||
|
url: state.context.url,
|
||||||
|
info: state.context.info,
|
||||||
|
pos: state.context.pos,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
this.viewStates.set(idx, viewState)
|
||||||
|
this.emit('state', [...this.viewStates.values()])
|
||||||
|
}
|
||||||
|
|
||||||
|
setViews(viewURLMap) {
|
||||||
|
const { views } = this
|
||||||
|
const boxes = boxesFromViewURLMap(GRID_COUNT, GRID_COUNT, viewURLMap)
|
||||||
|
|
||||||
|
const unusedViews = new Set(views)
|
||||||
|
for (const box of boxes) {
|
||||||
|
const { url, x, y, w, h, spaces } = box
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setListeningView(viewIdx) {
|
||||||
|
const { views } = this
|
||||||
|
for (const view of views) {
|
||||||
|
if (!view.state.matches('displaying')) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const { context } = view.state
|
||||||
|
const isSelectedView = context.pos.spaces.includes(viewIdx)
|
||||||
|
view.send(isSelectedView ? 'UNMUTE' : 'MUTE')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
send(...args) {
|
||||||
|
this.overlayView.webContents.send(...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,10 @@
|
|||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import yargs from 'yargs'
|
import yargs from 'yargs'
|
||||||
import { app, BrowserWindow, BrowserView, ipcMain, shell } from 'electron'
|
import { app, shell } from 'electron'
|
||||||
import { interpret } from 'xstate'
|
|
||||||
|
|
||||||
import { pollPublicData, pollSpreadsheetData, processData } from './data'
|
import { pollPublicData, pollSpreadsheetData, processData } from './data'
|
||||||
import viewStateMachine from './viewStateMachine'
|
import StreamWindow from './StreamWindow'
|
||||||
import { boxesFromViewURLMap } from './geometry'
|
import initWebServer from './server'
|
||||||
|
|
||||||
import {
|
|
||||||
WIDTH,
|
|
||||||
HEIGHT,
|
|
||||||
GRID_COUNT,
|
|
||||||
SPACE_WIDTH,
|
|
||||||
SPACE_HEIGHT,
|
|
||||||
} from '../constants'
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const argv = yargs
|
const argv = yargs
|
||||||
@@ -31,143 +22,66 @@ async function main() {
|
|||||||
.option('gs-tab', {
|
.option('gs-tab', {
|
||||||
describe: 'Google Spreadsheet tab name',
|
describe: 'Google Spreadsheet tab name',
|
||||||
})
|
})
|
||||||
|
.group(
|
||||||
|
['webserver', 'cert-dir', 'cert-email', 'hostname', 'port'],
|
||||||
|
'Web Server Configuration',
|
||||||
|
)
|
||||||
|
.option('webserver', {
|
||||||
|
describe: 'Enable control webserver and specify the URL',
|
||||||
|
implies: ['cert-dir', 'email', 'username', 'password'],
|
||||||
|
})
|
||||||
|
.option('cert-dir', {
|
||||||
|
describe: 'Private directory to store SSL certificate in',
|
||||||
|
})
|
||||||
|
.option('email', {
|
||||||
|
describe: 'Email for owner of SSL certificate',
|
||||||
|
})
|
||||||
|
.option('username', {
|
||||||
|
describe: 'Web control server username',
|
||||||
|
})
|
||||||
|
.option('password', {
|
||||||
|
describe: 'Web control server password',
|
||||||
|
})
|
||||||
|
.option('open-control', {
|
||||||
|
describe: 'After launching, open the control website in a browser',
|
||||||
|
boolean: true,
|
||||||
|
default: true,
|
||||||
|
})
|
||||||
.help().argv
|
.help().argv
|
||||||
|
|
||||||
const mainWin = new BrowserWindow({
|
const streamWindow = new StreamWindow()
|
||||||
x: 0,
|
streamWindow.init()
|
||||||
y: 0,
|
|
||||||
width: 800,
|
|
||||||
height: 600,
|
|
||||||
webPreferences: {
|
|
||||||
nodeIntegration: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await mainWin.loadFile('control.html')
|
|
||||||
mainWin.webContents.on('will-navigate', (ev, url) => {
|
|
||||||
ev.preventDefault()
|
|
||||||
shell.openExternal(url)
|
|
||||||
})
|
|
||||||
|
|
||||||
const streamWin = new BrowserWindow({
|
const clientState = {}
|
||||||
width: WIDTH,
|
const getInitialState = () => clientState
|
||||||
height: HEIGHT,
|
let broadcastState = () => {}
|
||||||
backgroundColor: '#000',
|
const onMessage = (msg) => {
|
||||||
useContentSize: true,
|
if (msg.type === 'set-views') {
|
||||||
show: false,
|
streamWindow.setViews(new Map(msg.views))
|
||||||
})
|
} else if (msg.type === 'set-listening-view') {
|
||||||
streamWin.removeMenu()
|
streamWindow.setListeningView(msg.viewIdx)
|
||||||
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 = []
|
if (argv.webserver) {
|
||||||
for (let idx = 0; idx <= 9; idx++) {
|
;({ broadcastState } = await initWebServer({
|
||||||
const view = new BrowserView()
|
certDir: argv.certDir,
|
||||||
view.setBackgroundColor('#000')
|
email: argv.email,
|
||||||
|
url: argv.webserver,
|
||||||
const machine = viewStateMachine
|
username: argv.username,
|
||||||
.withContext({
|
password: argv.password,
|
||||||
...viewStateMachine.context,
|
getInitialState,
|
||||||
view,
|
onMessage,
|
||||||
parentWin: streamWin,
|
}))
|
||||||
overlayView,
|
if (argv.openControl) {
|
||||||
})
|
shell.openExternal(argv.webserver)
|
||||||
.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, viewURLMap) => {
|
streamWindow.on('state', (viewStates) => {
|
||||||
const boxes = boxesFromViewURLMap(GRID_COUNT, GRID_COUNT, viewURLMap)
|
streamWindow.send('view-states', viewStates)
|
||||||
|
clientState.views = viewStates
|
||||||
const unusedViews = new Set(views)
|
broadcastState(clientState)
|
||||||
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()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
let dataGen
|
let dataGen
|
||||||
@@ -177,9 +91,10 @@ async function main() {
|
|||||||
dataGen = pollPublicData()
|
dataGen = pollPublicData()
|
||||||
}
|
}
|
||||||
|
|
||||||
for await (const data of processData(dataGen)) {
|
for await (const streams of processData(dataGen)) {
|
||||||
mainWin.webContents.send('stream-data', data)
|
streamWindow.send('stream-data', streams)
|
||||||
overlayView.webContents.send('stream-data', data)
|
clientState.streams = streams
|
||||||
|
broadcastState(clientState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +103,7 @@ if (require.main === module) {
|
|||||||
.whenReady()
|
.whenReady()
|
||||||
.then(main)
|
.then(main)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error(err.toString())
|
console.trace(err.toString())
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
120
src/node/server.js
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { app } from 'electron'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
import path from 'path'
|
||||||
|
import url from 'url'
|
||||||
|
import http from 'http'
|
||||||
|
import https from 'https'
|
||||||
|
import simpleCert from 'node-simple-cert'
|
||||||
|
import Koa from 'koa'
|
||||||
|
import auth from 'koa-basic-auth'
|
||||||
|
import route from 'koa-route'
|
||||||
|
import serveStatic from 'koa-static'
|
||||||
|
import views from 'koa-views'
|
||||||
|
import websocket from 'koa-easy-ws'
|
||||||
|
|
||||||
|
const webDistPath = path.join(app.getAppPath(), 'web')
|
||||||
|
|
||||||
|
function initApp({ username, password, baseURL, getInitialState, onMessage }) {
|
||||||
|
const sockets = new Set()
|
||||||
|
|
||||||
|
const app = new Koa()
|
||||||
|
|
||||||
|
// silence koa printing errors when websockets close early
|
||||||
|
app.silent = true
|
||||||
|
|
||||||
|
app.use(auth({ name: username, pass: password }))
|
||||||
|
app.use(views(webDistPath, { extension: 'ejs' }))
|
||||||
|
app.use(serveStatic(webDistPath))
|
||||||
|
app.use(websocket())
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
route.get('/', async (ctx) => {
|
||||||
|
await ctx.render('control', {
|
||||||
|
wsEndpoint: url.resolve(baseURL, 'ws').replace(/^http/, 'ws'),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
route.get('/ws', async (ctx) => {
|
||||||
|
if (ctx.ws) {
|
||||||
|
const ws = await ctx.ws()
|
||||||
|
sockets.add(ws)
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
sockets.delete(ws)
|
||||||
|
})
|
||||||
|
|
||||||
|
ws.on('message', (dataText) => {
|
||||||
|
let data
|
||||||
|
try {
|
||||||
|
data = JSON.parse(dataText)
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('received unexpected ws data:', dataText)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
onMessage(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('failed to handle ws message:', data, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const state = getInitialState()
|
||||||
|
ws.send(JSON.stringify({ type: 'state', state }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.status = 404
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const broadcastState = (state) => {
|
||||||
|
for (const ws of sockets) {
|
||||||
|
ws.send(JSON.stringify({ type: 'state', state }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { app, broadcastState }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function initWebServer({
|
||||||
|
certDir,
|
||||||
|
email,
|
||||||
|
url: baseURL,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
getInitialState,
|
||||||
|
onMessage,
|
||||||
|
}) {
|
||||||
|
let { protocol, hostname, port } = new URL(baseURL)
|
||||||
|
if (!port) {
|
||||||
|
port = protocol === 'https' ? 443 : 80
|
||||||
|
}
|
||||||
|
|
||||||
|
const { app, broadcastState } = initApp({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
baseURL,
|
||||||
|
getInitialState,
|
||||||
|
onMessage,
|
||||||
|
})
|
||||||
|
|
||||||
|
let server
|
||||||
|
if (protocol === 'https:') {
|
||||||
|
const { key, cert } = await simpleCert({
|
||||||
|
dataDir: certDir,
|
||||||
|
commonName: hostname,
|
||||||
|
email,
|
||||||
|
production: process.env.NODE_DEV === 'production',
|
||||||
|
serverHost: hostname,
|
||||||
|
})
|
||||||
|
server = https.createServer({ key, cert }, app.callback())
|
||||||
|
} else {
|
||||||
|
server = http.createServer(app.callback())
|
||||||
|
}
|
||||||
|
|
||||||
|
const listen = promisify(server.listen).bind(server)
|
||||||
|
await listen(port, hostname)
|
||||||
|
|
||||||
|
return { broadcastState }
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 344 B After Width: | Height: | Size: 344 B |
|
Before Width: | Height: | Size: 1002 B After Width: | Height: | Size: 1002 B |
|
Before Width: | Height: | Size: 608 B After Width: | Height: | Size: 608 B |
|
Before Width: | Height: | Size: 281 B After Width: | Height: | Size: 281 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 550 B After Width: | Height: | Size: 550 B |
11
src/web/.babelrc.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"presets": [
|
||||||
|
[
|
||||||
|
"@babel/preset-env",
|
||||||
|
{
|
||||||
|
"modules": "commonjs",
|
||||||
|
"targets": "> 0.25%, not dead"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -9,6 +9,11 @@
|
|||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<script src="control.js" type="module"></script>
|
<script
|
||||||
|
src="control.js"
|
||||||
|
type="module"
|
||||||
|
id="main-script"
|
||||||
|
data-ws-endpoint="<%= wsEndpoint %>"
|
||||||
|
></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,21 +1,57 @@
|
|||||||
import { ipcRenderer } from 'electron'
|
|
||||||
import range from 'lodash/range'
|
import range from 'lodash/range'
|
||||||
|
import ReconnectingWebSocket from 'reconnecting-websocket'
|
||||||
import { h, render } from 'preact'
|
import { h, render } from 'preact'
|
||||||
import { useEffect, useState, useCallback } from 'preact/hooks'
|
import { useEffect, useState, useCallback, useRef } from 'preact/hooks'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
import './index.css'
|
import '../index.css'
|
||||||
import SoundIcon from './static/volume-up-solid.svg'
|
import SoundIcon from '../static/volume-up-solid.svg'
|
||||||
|
|
||||||
function App() {
|
function App({ wsEndpoint }) {
|
||||||
|
const wsRef = useRef()
|
||||||
|
const [isConnected, setIsConnected] = useState(false)
|
||||||
const [streamData, setStreamData] = useState()
|
const [streamData, setStreamData] = useState()
|
||||||
const [spaceIdxMap, setSpaceIdxMap] = useState(new Map())
|
const [spaceIdxMap, setSpaceIdxMap] = useState(new Map())
|
||||||
const [listeningIdx, setListeningIdx] = useState()
|
const [listeningIdxSet, setListeningIdxSet] = useState(new Set())
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ipcRenderer.on('stream-data', (ev, data) => {
|
const ws = new ReconnectingWebSocket(wsEndpoint, [], {
|
||||||
setStreamData(data)
|
maxReconnectionDelay: 5000,
|
||||||
|
minReconnectionDelay: 1000 + Math.random() * 500,
|
||||||
|
reconnectionDelayGrowFactor: 1.1,
|
||||||
})
|
})
|
||||||
|
ws.addEventListener('open', () => setIsConnected(true))
|
||||||
|
ws.addEventListener('close', () => setIsConnected(false))
|
||||||
|
ws.addEventListener('message', (ev) => {
|
||||||
|
const msg = JSON.parse(ev.data)
|
||||||
|
if (msg.type === 'state') {
|
||||||
|
const { streams, views } = msg.state
|
||||||
|
setStreamData(streams)
|
||||||
|
|
||||||
|
const newSpaceIdxMap = new Map()
|
||||||
|
const newListeningIdxSet = new Set()
|
||||||
|
for (const viewState of views) {
|
||||||
|
const { pos, url } = viewState.context
|
||||||
|
if (!url) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const streamId = streams.find((d) => d.Link === url)?._id
|
||||||
|
const isListening =
|
||||||
|
viewState.state.displaying?.running === 'listening'
|
||||||
|
for (const space of pos.spaces) {
|
||||||
|
newSpaceIdxMap.set(space, streamId)
|
||||||
|
if (isListening) {
|
||||||
|
newListeningIdxSet.add(space)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSpaceIdxMap(newSpaceIdxMap)
|
||||||
|
setListeningIdxSet(newListeningIdxSet)
|
||||||
|
} else {
|
||||||
|
console.warn('unexpected ws message', msg)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
wsRef.current = ws
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSetSpace = useCallback(
|
const handleSetSpace = useCallback(
|
||||||
@@ -28,29 +64,30 @@ function App() {
|
|||||||
}
|
}
|
||||||
setSpaceIdxMap(newSpaceIdxMap)
|
setSpaceIdxMap(newSpaceIdxMap)
|
||||||
|
|
||||||
const newSpaceURLMap = new Map(
|
const views = Array.from(newSpaceIdxMap, ([spaceIdx, streamId]) => [
|
||||||
Array.from(newSpaceIdxMap, ([spaceIdx, streamId]) => [
|
spaceIdx,
|
||||||
spaceIdx,
|
streamData.find((d) => d._id === streamId)?.Link,
|
||||||
streamData.find((d) => d._id === streamId)?.Link,
|
]).filter(([s, i]) => i)
|
||||||
]),
|
wsRef.current.send(JSON.stringify({ type: 'set-views', views }))
|
||||||
)
|
|
||||||
ipcRenderer.send('set-videos', newSpaceURLMap)
|
|
||||||
},
|
},
|
||||||
[streamData, spaceIdxMap],
|
[streamData, spaceIdxMap],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleSetListening = useCallback(
|
const handleSetListening = useCallback((idx, listening) => {
|
||||||
(idx) => {
|
wsRef.current.send(
|
||||||
const newIdx = idx === listeningIdx ? null : idx
|
JSON.stringify({
|
||||||
setListeningIdx(newIdx)
|
type: 'set-listening-view',
|
||||||
ipcRenderer.send('set-sound-source', newIdx)
|
viewIdx: listening ? idx : null,
|
||||||
},
|
}),
|
||||||
[listeningIdx],
|
)
|
||||||
)
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1>Stream Wall</h1>
|
<h1>Stream Wall</h1>
|
||||||
|
<div>
|
||||||
|
connection status: {isConnected ? 'connected' : 'connecting...'}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{range(0, 3).map((y) => (
|
{range(0, 3).map((y) => (
|
||||||
<StyledGridLine>
|
<StyledGridLine>
|
||||||
@@ -61,7 +98,7 @@ function App() {
|
|||||||
idx={idx}
|
idx={idx}
|
||||||
onChangeSpace={handleSetSpace}
|
onChangeSpace={handleSetSpace}
|
||||||
spaceValue={spaceIdxMap.get(idx)}
|
spaceValue={spaceIdxMap.get(idx)}
|
||||||
isListening={idx === listeningIdx}
|
isListening={listeningIdxSet.has(idx)}
|
||||||
onSetListening={handleSetListening}
|
onSetListening={handleSetListening}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
@@ -96,17 +133,25 @@ function GridInput({
|
|||||||
isListening,
|
isListening,
|
||||||
onSetListening,
|
onSetListening,
|
||||||
}) {
|
}) {
|
||||||
|
const [editingValue, setEditingValue] = useState()
|
||||||
|
const handleFocus = useCallback((ev) => {
|
||||||
|
setEditingValue(ev.target.value)
|
||||||
|
})
|
||||||
|
const handleBlur = useCallback((ev) => {
|
||||||
|
setEditingValue(undefined)
|
||||||
|
})
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(ev) => {
|
(ev) => {
|
||||||
const { name, value } = ev.target
|
const { name, value } = ev.target
|
||||||
|
setEditingValue(value)
|
||||||
onChangeSpace(Number(name), value)
|
onChangeSpace(Number(name), value)
|
||||||
},
|
},
|
||||||
[onChangeSpace],
|
[onChangeSpace],
|
||||||
)
|
)
|
||||||
const handleListeningClick = useCallback(() => onSetListening(idx), [
|
const handleListeningClick = useCallback(
|
||||||
idx,
|
() => onSetListening(idx, !isListening),
|
||||||
onSetListening,
|
[idx, onSetListening, isListening],
|
||||||
])
|
)
|
||||||
const handleClick = useCallback((ev) => {
|
const handleClick = useCallback((ev) => {
|
||||||
ev.target.select()
|
ev.target.select()
|
||||||
})
|
})
|
||||||
@@ -118,7 +163,9 @@ function GridInput({
|
|||||||
/>
|
/>
|
||||||
<StyledGridInput
|
<StyledGridInput
|
||||||
name={idx}
|
name={idx}
|
||||||
value={spaceValue}
|
value={editingValue || spaceValue || ''}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
@@ -199,7 +246,8 @@ const StyledStreamLine = styled.div`
|
|||||||
`
|
`
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
render(<App />, document.body)
|
const script = document.getElementById('main-script')
|
||||||
|
render(<App wsEndpoint={script.dataset.wsEndpoint} />, document.body)
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const path = require('path')
|
||||||
const CopyPlugin = require('copy-webpack-plugin')
|
const CopyPlugin = require('copy-webpack-plugin')
|
||||||
|
|
||||||
const baseConfig = {
|
const baseConfig = {
|
||||||
@@ -32,6 +33,13 @@ const baseConfig = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
resolve: {
|
||||||
|
extensions: ['.jsx', '.js'],
|
||||||
|
alias: {
|
||||||
|
react: 'preact/compat',
|
||||||
|
'react-dom': 'preact/compat',
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const nodeConfig = {
|
const nodeConfig = {
|
||||||
@@ -40,6 +48,9 @@ const nodeConfig = {
|
|||||||
entry: {
|
entry: {
|
||||||
index: './src/node/index.js',
|
index: './src/node/index.js',
|
||||||
},
|
},
|
||||||
|
externals: {
|
||||||
|
consolidate: 'commonjs consolidate',
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const browserConfig = {
|
const browserConfig = {
|
||||||
@@ -47,24 +58,30 @@ const browserConfig = {
|
|||||||
devtool: 'cheap-source-map',
|
devtool: 'cheap-source-map',
|
||||||
target: 'electron-renderer',
|
target: 'electron-renderer',
|
||||||
entry: {
|
entry: {
|
||||||
control: './src/browser/control.js',
|
|
||||||
overlay: './src/browser/overlay.js',
|
overlay: './src/browser/overlay.js',
|
||||||
},
|
},
|
||||||
resolve: {
|
|
||||||
extensions: ['.jsx', '.js'],
|
|
||||||
alias: {
|
|
||||||
react: 'preact/compat',
|
|
||||||
'react-dom': 'preact/compat',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [
|
plugins: [
|
||||||
new CopyPlugin({
|
new CopyPlugin({
|
||||||
patterns: [
|
patterns: [{ from: 'src/browser/overlay.html', to: '[name].html' }],
|
||||||
{ from: 'src/**/*.html', to: '[name].html' },
|
|
||||||
{ from: 'src/**/*.ttf', to: '[name].ttf' },
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = [nodeConfig, browserConfig]
|
const webConfig = {
|
||||||
|
...baseConfig,
|
||||||
|
devtool: 'cheap-source-map',
|
||||||
|
target: 'web',
|
||||||
|
entry: {
|
||||||
|
control: './src/web/control.js',
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
path: path.resolve(__dirname, 'dist/web'),
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new CopyPlugin({
|
||||||
|
patterns: [{ from: 'src/web/*.ejs', to: '[name].ejs' }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = [nodeConfig, browserConfig, webConfig]
|
||||||
|
|||||||