Initial v2 overhaul

This commit is contained in:
Max Goodhart
2025-02-22 15:49:01 -08:00
parent 9c9215487f
commit a76abc39ee
91 changed files with 11832 additions and 14218 deletions

View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -0,0 +1,27 @@
{
"name": "streamwall-control-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "./src/index.tsx",
"dependencies": {
"@fontsource/noto-sans": "^5.1.1",
"color": "^5.0.0",
"jsondiffpatch": "^0.6.0",
"lodash-es": "^4.17.21",
"luxon": "^3.5.0",
"preact": "^10.25.3",
"react-hotkeys-hook": "^4.6.1",
"react-icons": "^5.4.0",
"reconnecting-websocket": "^4.4.0",
"styled-components": "^6.1.14",
"xstate": "^5.19.1",
"yjs": "^13.6.21"
},
"devDependencies": {
"@preact/preset-vite": "^2.9.3",
"@types/lodash-es": "^4.17.12",
"@types/luxon": "^3.4.2",
"typescript": "~5.6.2"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2024",
"module": "commonjs",
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"noImplicitAny": true,
"strictNullChecks": true,
"sourceMap": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
"paths": {
"react": ["./node_modules/preact/compat/"],
"react-dom": ["./node_modules/preact/compat/"]
},
"lib": ["DOM.iterable"],
"baseUrl": ".",
"outDir": "dist",
"moduleResolution": "node",
"resolveJsonModule": true
}
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import preact from '@preact/preset-vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [preact()],
})

View File

@@ -0,0 +1,13 @@
{
"name": "streamwall-shared",
"private": true,
"version": "0.0.0",
"type": "module",
"main": "./src/index.ts",
"dependencies": {
"color": "^5.0.0"
},
"devDependencies": {
"typescript": "~5.6.2"
}
}

View File

@@ -0,0 +1,32 @@
import Color from 'color'
export function hashText(text: string, range: number) {
// DJBX33A-ish
// based on https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/hueHash.js#L16-L45
let val = 0
for (let i = 0; i < text.length; i++) {
// Multiply by an arbitrary prime number to spread out similar letters.
const charVal = (text.charCodeAt(i) * 401) % range
// Multiply val by 33 while constraining within signed 32 bit int range.
// this keeps the value within Number.MAX_SAFE_INTEGER without throwing out
// information.
const origVal = val
val = val << 5
val += origVal
// Add the character to the hash.
val += charVal
}
return (val + range) % range
}
export function idColor(id: string) {
if (!id) {
return Color('white')
}
const h = hashText(id, 360)
const sPart = hashText(id, 40)
return Color({ h, s: 20 + sPart, l: 50 })
}

View File

@@ -0,0 +1,109 @@
import { Rectangle } from 'electron'
import { isEqual } from 'lodash-es'
import { ContentKind } from './types'
export interface ViewPos extends Rectangle {
/**
* Grid space indexes inhabited by the view.
*/
spaces: number[]
}
export interface ViewContent {
url: string
kind: ContentKind
}
export type ViewContentMap = Map<string, ViewContent>
export function boxesFromViewContentMap(
width: number,
height: number,
viewContentMap: ViewContentMap,
) {
const boxes = []
const visited = new Set()
function isPosContent(
x: number,
y: number,
content: ViewContent | undefined,
) {
const checkIdx = width * y + x
return (
!visited.has(checkIdx) &&
isEqual(viewContentMap.get(String(checkIdx)), content)
)
}
function findLargestBox(x: number, y: number) {
const idx = width * y + x
const spaces = [idx]
const content = viewContentMap.get(String(idx))
let maxY
for (maxY = y + 1; maxY < height; maxY++) {
if (!isPosContent(x, maxY, content)) {
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++) {
if (!isPosContent(cx, cy, content)) {
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 { content, 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) || viewContentMap.get(String(idx)) === undefined) {
continue
}
const box = findLargestBox(x, y)
boxes.push(box)
for (const boxIdx of box.spaces) {
visited.add(boxIdx)
}
}
}
return boxes
}
export function idxToCoords(gridCount: number, idx: number) {
const x = idx % gridCount
const y = Math.floor(idx / gridCount)
return { x, y }
}
export function idxInBox(
gridCount: number,
start: number,
end: number,
idx: number,
) {
const { x: startX, y: startY } = idxToCoords(gridCount, start)
const { x: endX, y: endY } = idxToCoords(gridCount, end)
const { x, y } = idxToCoords(gridCount, idx)
const lowX = Math.min(startX, endX)
const highX = Math.max(startX, endX)
const lowY = Math.min(startY, endY)
const highY = Math.max(startY, endY)
const xInBox = x >= lowX && x <= highX
const yInBox = y >= lowY && y <= highY
return xInBox && yInBox
}

View File

@@ -0,0 +1,4 @@
export * from './colors'
export * from './geometry'
export * from './roles'
export * from './types'

View File

@@ -0,0 +1,43 @@
export const validRoles = ['local', 'admin', 'operator', 'monitor'] as const
const adminActions = ['dev-tools', 'browse', 'edit-tokens'] as const
const operatorActions = [
'set-listening-view',
'set-view-background-listening',
'set-view-blurred',
'update-custom-stream',
'delete-custom-stream',
'rotate-stream',
'reload-view',
'set-stream-censored',
'set-stream-running',
'mutate-state-doc',
] as const
const monitorActions = ['set-view-blurred', 'set-stream-censored'] as const
export type StreamwallRole = (typeof validRoles)[number]
export type StreamwallAction =
| (typeof adminActions)[number]
| (typeof operatorActions)[number]
| (typeof monitorActions)[number]
const operatorActionSet = new Set<StreamwallAction>(operatorActions)
const monitorActionSet = new Set<StreamwallAction>(monitorActions)
export function roleCan(role: StreamwallRole | null, action: StreamwallAction) {
if (role === 'admin' || role === 'local') {
return true
}
if (role === 'operator' && operatorActionSet.has(action)) {
return true
}
if (role === 'monitor' && monitorActionSet.has(action)) {
return true
}
return false
}

View File

@@ -0,0 +1,102 @@
import { ViewContent, ViewPos } from './geometry'
export interface StreamWindowConfig {
gridCount: number
width: number
height: number
x?: number
y?: number
frameless: boolean
activeColor: string
backgroundColor: string
}
export interface ContentDisplayOptions {
rotation?: number
}
/** Metadata scraped from a loaded view */
export interface ContentViewInfo {
title: string
}
export type ContentKind = 'video' | 'audio' | 'web' | 'background' | 'overlay'
export interface StreamData extends ContentDisplayOptions {
kind: ContentKind
link: string
label: string
labelPosition?: 'top-left' | 'top-right' | 'bottom-right' | 'bottom-left'
source?: string
notes?: string
status?: string
_id: string
_dataSource: string
}
export type LocalStreamData = Omit<StreamData, '_id' | '_dataSource'>
export type StreamList = StreamData[] & { byURL?: Map<string, StreamData> }
// matches viewStateMachine.ts
export type ViewStateValue =
| 'empty'
| {
displaying:
| 'error'
| {
loading: 'navigate' | 'waitForInit' | 'waitForVideo'
}
| {
running: {
video: 'normal' | 'blurred'
audio: 'background' | 'muted' | 'listening'
}
}
}
export interface ViewState {
state: ViewStateValue
context: {
id: number
content: ViewContent | null
info: ContentViewInfo | null
pos: ViewPos | null
}
}
export interface StreamDelayStatus {
isConnected: boolean
delaySeconds: number
restartSeconds: number
isCensored: boolean
isStreamRunning: boolean
startTime: number
state: string
}
export interface StreamwallState {
config: StreamWindowConfig
streams: StreamList
views: ViewState[]
streamdelay: StreamDelayStatus | null
}
export type ControlCommand =
| { type: 'set-listening-view'; viewIdx: number | null }
| {
type: 'set-view-background-listening'
viewIdx: number
listening: boolean
}
| { type: 'set-view-blurred'; viewIdx: number; blurred: boolean }
| { type: 'rotate-stream'; url: string; rotation: number }
| { type: 'update-custom-stream'; url: string; data: LocalStreamData }
| { type: 'delete-custom-stream'; url: string }
| { type: 'reload-view'; viewIdx: number }
| { type: 'browse'; url: string }
| { type: 'dev-tools'; viewIdx: number }
| { type: 'set-stream-censored'; isCensored: boolean }
| { type: 'set-stream-running'; isStreamRunning: boolean }
| { type: 'create-invite'; role: string; name: string }
| { type: 'delete-token'; tokenId: string }

View File

@@ -0,0 +1,31 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"paths": {
"react": ["./node_modules/preact/compat/"],
"react-dom": ["./node_modules/preact/compat/"]
},
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

92
packages/streamwall/.gitignore vendored Normal file
View File

@@ -0,0 +1,92 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
.DS_Store
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
.env.test
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Webpack
.webpack/
# Vite
.vite/
# Electron-Forge
out/

View File

@@ -0,0 +1,76 @@
import { MakerDeb } from '@electron-forge/maker-deb'
import { MakerRpm } from '@electron-forge/maker-rpm'
import { MakerSquirrel } from '@electron-forge/maker-squirrel'
import { MakerZIP } from '@electron-forge/maker-zip'
import { FusesPlugin } from '@electron-forge/plugin-fuses'
import { VitePlugin } from '@electron-forge/plugin-vite'
import type { ForgeConfig } from '@electron-forge/shared-types'
import { FuseV1Options, FuseVersion } from '@electron/fuses'
const config: ForgeConfig = {
packagerConfig: {
asar: true,
},
rebuildConfig: {},
makers: [
new MakerSquirrel({}),
new MakerZIP({}, ['darwin']),
new MakerRpm({}),
new MakerDeb({}),
],
publishers: [
{
name: '@electron-forge/publisher-github',
config: {
repository: {
owner: 'streamwall',
name: 'streamwall',
},
prerelease: true,
},
},
],
plugins: [
new VitePlugin({
build: [
{
entry: 'src/main/index.ts',
config: 'vite.main.config.ts',
target: 'main',
},
{
entry: 'src/preload/layerPreload.ts',
config: 'vite.preload.config.ts',
target: 'preload',
},
{
entry: 'src/preload/mediaPreload.ts',
config: 'vite.preload.config.ts',
target: 'preload',
},
{
entry: 'src/preload/controlPreload.ts',
config: 'vite.preload.config.ts',
target: 'preload',
},
],
renderer: [
{
name: 'main_window',
config: 'vite.renderer.config.ts',
},
],
}),
new FusesPlugin({
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableCookieEncryption]: true,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: true,
}),
],
}
export default config

1
packages/streamwall/forge.env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="@electron-forge/plugin-vite/forge-vite-env" />

View File

@@ -0,0 +1,68 @@
{
"name": "streamwall",
"productName": "Streamwall",
"version": "2.0.0",
"description": "Watch streams in a grid layout",
"main": ".vite/build/index.js",
"scripts": {
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
"publish": "electron-forge publish",
"lint": "eslint --ext .ts,.tsx ."
},
"devDependencies": {
"@electron-forge/cli": "^7.6.0",
"@electron-forge/maker-deb": "^7.6.0",
"@electron-forge/maker-rpm": "^7.6.0",
"@electron-forge/maker-squirrel": "^7.6.0",
"@electron-forge/maker-zip": "^7.6.0",
"@electron-forge/plugin-auto-unpack-natives": "^7.6.0",
"@electron-forge/plugin-fuses": "^7.6.0",
"@electron-forge/plugin-vite": "^7.6.0",
"@electron-forge/publisher-github": "^7.7.0",
"@electron/fuses": "^1.8.0",
"@preact/preset-vite": "^2.10.1",
"@types/color": "^4.2.0",
"@types/electron-squirrel-startup": "^1.0.2",
"@types/lodash-es": "^4.17.12",
"@types/ws": "^8.5.13",
"@types/yargs": "^17.0.33",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"electron": "^33.2.1",
"eslint": "^8.57.1",
"eslint-plugin-import": "^2.31.0",
"ts-node": "^10.9.2",
"typescript": "~4.5.4",
"vite": "^5.4.14"
},
"keywords": [],
"author": "Max Goodhart <c@chromakode.com>",
"license": "MIT",
"dependencies": {
"@fontsource/noto-sans": "^5.1.1",
"@iarna/toml": "^2.2.5",
"@repeaterjs/repeater": "^3.0.6",
"@sentry/electron": "^5.9.0",
"bufferutil": "^4.0.9",
"chokidar": "^4.0.3",
"color": "^5.0.0",
"electron-squirrel-startup": "^1.0.1",
"esbuild-register": "^3.6.0",
"hls.js": "^1.5.18",
"lodash-es": "^4.17.21",
"node-fetch": "^3.3.2",
"react-hotkeys-hook": "^4.6.1",
"react-icons": "^5.4.0",
"reconnecting-websocket": "^4.4.0",
"source-map-support": "^0.5.21",
"styled-components": "^6.1.13",
"svg-loaders-react": "^3.1.1",
"update-electron-app": "^3.1.1",
"utf-8-validate": "^5.0.10",
"ws": "^7.5.10",
"xstate": "^5.19.1",
"yjs": "^13.6.21"
}
}

View File

@@ -0,0 +1,68 @@
import { BrowserWindow, ipcMain } from 'electron'
import EventEmitter from 'events'
import path from 'path'
import { ControlCommand, StreamwallState } from 'streamwall-shared'
import { loadHTML } from './loadHTML'
export interface ControlWindowEventMap {
load: []
close: []
command: [ControlCommand]
ydoc: [Uint8Array]
}
export default class ControlWindow extends EventEmitter<ControlWindowEventMap> {
win: BrowserWindow
constructor() {
super()
this.win = new BrowserWindow({
title: 'Streamwall Control',
width: 1280,
height: 1024,
closable: false,
webPreferences: {
preload: path.join(__dirname, 'controlPreload.js'),
},
})
this.win.removeMenu()
this.win.on('close', () => this.emit('close'))
loadHTML(this.win.webContents, 'control')
ipcMain.handle('control:load', (ev) => {
if (ev.sender !== this.win.webContents) {
return
}
this.emit('load')
})
ipcMain.handle('control:devtools', () => {
this.win.webContents.openDevTools()
})
ipcMain.handle('control:command', (ev, command) => {
if (ev.sender !== this.win.webContents) {
return
}
this.emit('command', command)
})
ipcMain.handle('control:ydoc', (ev, update) => {
if (ev.sender !== this.win.webContents) {
return
}
this.emit('ydoc', update)
})
}
onState(state: StreamwallState) {
this.win.webContents.send('state', state)
}
onYDocUpdate(update: Uint8Array) {
this.win.webContents.send('ydoc', update)
}
}

View File

@@ -0,0 +1,364 @@
import assert from 'assert'
import { BrowserWindow, ipcMain, WebContents, WebContentsView } from 'electron'
import EventEmitter from 'events'
import intersection from 'lodash/intersection'
import isEqual from 'lodash/isEqual'
import path from 'path'
import {
boxesFromViewContentMap,
ContentDisplayOptions,
StreamData,
StreamList,
StreamwallState,
StreamWindowConfig,
ViewContent,
ViewContentMap,
ViewState,
} from 'streamwall-shared'
import { createActor, EventFrom, SnapshotFrom } from 'xstate'
import { loadHTML } from './loadHTML'
import viewStateMachine, { ViewActor } from './viewStateMachine'
function getDisplayOptions(stream: StreamData): ContentDisplayOptions {
if (!stream) {
return {}
}
const { rotation } = stream
return { rotation }
}
export interface StreamWindowEventMap {
load: []
close: []
state: [ViewState[]]
}
export default class StreamWindow extends EventEmitter<StreamWindowEventMap> {
config: StreamWindowConfig
win: BrowserWindow
offscreenWin: BrowserWindow
backgroundView: WebContentsView
overlayView: WebContentsView
views: Map<number, ViewActor>
constructor(config: StreamWindowConfig) {
super()
this.config = config
this.views = new Map()
const { width, height, x, y, frameless, backgroundColor } = this.config
const win = new BrowserWindow({
title: 'Streamwall',
width,
height,
x,
y,
frame: !frameless,
backgroundColor,
useContentSize: true,
show: false,
})
win.removeMenu()
win.loadURL('about:blank')
win.on('close', () => this.emit('close'))
// 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 offscreenWin = new BrowserWindow({
width,
height,
show: false,
})
this.offscreenWin = offscreenWin
const backgroundView = new WebContentsView({
webPreferences: {
preload: path.join(__dirname, 'layerPreload.js'),
},
})
backgroundView.setBackgroundColor('#0000')
win.contentView.addChildView(backgroundView)
backgroundView.setBounds({
x: 0,
y: 0,
width,
height,
})
loadHTML(backgroundView.webContents, 'background')
this.backgroundView = backgroundView
const overlayView = new WebContentsView({
webPreferences: {
contextIsolation: true,
preload: path.join(__dirname, 'layerPreload.js'),
},
})
overlayView.setBackgroundColor('#0000')
win.contentView.addChildView(overlayView)
overlayView.setBounds({
x: 0,
y: 0,
width,
height,
})
loadHTML(overlayView.webContents, 'overlay')
this.overlayView = overlayView
ipcMain.handle('layer:load', (ev) => {
if (
ev.sender !== this.backgroundView.webContents &&
ev.sender !== this.overlayView.webContents
) {
return
}
this.emit('load')
})
ipcMain.handle('view-init', async (ev) => {
const view = this.views.get(ev.sender.id)
if (view) {
view.send({ type: 'VIEW_INIT' })
const { content, options } = view.getSnapshot().context
return {
content,
options,
}
}
})
ipcMain.on('view-loaded', (ev) => {
this.views.get(ev.sender.id)?.send?.({ type: 'VIEW_LOADED' })
})
ipcMain.on('view-info', (ev, { info }) => {
this.views.get(ev.sender.id)?.send?.({ type: 'VIEW_INFO', info })
})
ipcMain.on('view-error', (ev, { error }) => {
this.views.get(ev.sender.id)?.send?.({ type: 'VIEW_ERROR', error })
})
ipcMain.on('devtools-overlay', () => {
overlayView.webContents.openDevTools()
})
}
createView() {
const { win, offscreenWin } = this
assert(win != null, 'Window must be initialized')
const { backgroundColor } = this.config
const view = new WebContentsView({
webPreferences: {
preload: path.join(__dirname, 'mediaPreload.js'),
nodeIntegration: false,
contextIsolation: true,
partition: 'persist:session',
},
})
view.setBackgroundColor(backgroundColor)
const viewId = view.webContents.id
// Prevent view pages from navigating away from the specified URL.
view.webContents.on('will-navigate', (ev) => {
ev.preventDefault()
})
const actor = createActor(viewStateMachine, {
input: {
id: viewId,
view,
win,
offscreenWin,
},
})
let lastSnapshot: SnapshotFrom<typeof viewStateMachine> | undefined
actor.subscribe((snapshot) => {
if (snapshot === lastSnapshot) {
return
}
lastSnapshot = snapshot
this.emitState()
})
actor.start()
return actor
}
emitState() {
const states = Array.from(this.views.values(), (actor) => {
const { value, context } = actor.getSnapshot()
return {
state: value,
context: {
id: context.id,
content: context.content,
info: context.info,
pos: context.pos,
},
} satisfies ViewState
})
this.emit('state', states)
}
setViews(viewContentMap: ViewContentMap, streams: StreamList) {
const { width, height, gridCount } = this.config
const spaceWidth = Math.floor(width / gridCount)
const spaceHeight = Math.floor(height / gridCount)
const { win, views } = this
const boxes = boxesFromViewContentMap(gridCount, gridCount, viewContentMap)
const remainingBoxes = new Set(boxes)
const unusedViews = new Set(views.values())
const viewsToDisplay = []
// We try to find the best match for moving / reusing existing views to match the new positions.
const matchers: Array<
(
v: SnapshotFrom<typeof viewStateMachine>,
content: ViewContent | undefined,
spaces?: number[],
) => boolean
> = [
// First try to find a loaded view of the same URL in the same space...
(v, content, spaces) =>
isEqual(v.context.content, content) &&
v.matches({ displaying: 'running' }) &&
intersection(v.context.pos?.spaces, spaces).length > 0,
// Then try to find a loaded view of the same URL...
(v, content) =>
isEqual(v.context.content, content) &&
v.matches({ displaying: 'running' }),
// Then try view with the same URL that is still loading...
(v, content) => isEqual(v.context.content, content),
]
for (const matcher of matchers) {
for (const box of remainingBoxes) {
const { content, spaces } = box
let foundView
for (const view of unusedViews) {
const snapshot = view.getSnapshot()
if (matcher(snapshot, content, spaces)) {
foundView = view
break
}
}
if (foundView) {
viewsToDisplay.push({ box, view: foundView })
unusedViews.delete(foundView)
remainingBoxes.delete(box)
}
}
}
for (const box of remainingBoxes) {
const view = this.createView()
viewsToDisplay.push({ box, view })
}
const newViews = new Map()
for (const { box, view } of viewsToDisplay) {
const { content, x, y, w, h, spaces } = box
if (!content) {
continue
}
const stream = streams.byURL?.get(content.url)
if (!stream) {
continue
}
const pos = {
x: spaceWidth * x,
y: spaceHeight * y,
width: spaceWidth * w,
height: spaceHeight * h,
spaces,
}
view.send({ type: 'DISPLAY', pos, content })
view.send({ type: 'OPTIONS', options: getDisplayOptions(stream) })
newViews.set(view.getSnapshot().context.id, view)
}
for (const view of unusedViews) {
const contentView = view.getSnapshot().context.view
win.contentView.removeChildView(contentView)
}
this.views = newViews
this.emitState()
}
setListeningView(viewIdx: number | null) {
const { views } = this
for (const view of views.values()) {
const snapshot = view.getSnapshot()
if (!snapshot.matches('displaying')) {
continue
}
const { context } = snapshot
const isSelectedView =
viewIdx != null
? (context.pos?.spaces.includes(viewIdx) ?? false)
: false
view.send({ type: isSelectedView ? 'UNMUTE' : 'MUTE' })
}
}
findViewByIdx(viewIdx: number) {
for (const view of this.views.values()) {
if (view.getSnapshot().context.pos?.spaces?.includes?.(viewIdx)) {
return view
}
}
}
sendViewEvent(viewIdx: number, event: EventFrom<typeof viewStateMachine>) {
const view = this.findViewByIdx(viewIdx)
if (view) {
view.send(event)
}
}
setViewBackgroundListening(viewIdx: number, listening: boolean) {
this.sendViewEvent(viewIdx, {
type: listening ? 'BACKGROUND' : 'UNBACKGROUND',
})
}
setViewBlurred(viewIdx: number, blurred: boolean) {
this.sendViewEvent(viewIdx, { type: blurred ? 'BLUR' : 'UNBLUR' })
}
reloadView(viewIdx: number) {
this.sendViewEvent(viewIdx, { type: 'RELOAD' })
}
openDevTools(viewIdx: number, inWebContents: WebContents) {
this.sendViewEvent(viewIdx, { type: 'DEVTOOLS', inWebContents })
}
onState(state: StreamwallState) {
this.overlayView.webContents.send('state', state)
this.backgroundView.webContents.send('state', state)
for (const view of this.views.values()) {
const { content } = view.getSnapshot().context
if (!content) {
continue
}
const { url } = content
const stream = state.streams.byURL?.get(url)
if (stream) {
view.send({
type: 'OPTIONS',
options: getDisplayOptions(stream),
})
}
}
}
}

View File

@@ -0,0 +1,74 @@
import assert from 'assert'
import EventEmitter from 'events'
import ReconnectingWebSocket from 'reconnecting-websocket'
import { StreamDelayStatus } from 'streamwall-shared'
import * as url from 'url'
import WebSocket from 'ws'
export interface StreamdelayClientOptions {
endpoint: string
key: string
}
export default class StreamdelayClient extends EventEmitter {
endpoint: string
key: string
ws: ReconnectingWebSocket | null
status: StreamDelayStatus | null
constructor({ endpoint, key }: StreamdelayClientOptions) {
super()
this.endpoint = endpoint
this.key = key
this.ws = null
this.status = null
}
connect() {
const wsURL = url.resolve(
this.endpoint.replace(/^http/, 'ws'),
`ws?key=${this.key}`,
)
const ws = (this.ws = new ReconnectingWebSocket(wsURL, [], {
WebSocket,
maxReconnectionDelay: 5000,
minReconnectionDelay: 1000 + Math.random() * 500,
reconnectionDelayGrowFactor: 1.1,
}))
ws.addEventListener('open', () => this.emitState())
ws.addEventListener('close', () => this.emitState())
ws.addEventListener('message', (ev) => {
let data
try {
data = JSON.parse(ev.data)
} catch (err) {
console.error('invalid JSON from streamdelay:', ev.data)
return
}
this.status = data.status
this.emitState()
})
}
emitState() {
const isConnected = this.ws?.readyState === WebSocket.OPEN
if (isConnected && !this.status) {
// Wait until we've received the first status message
return
}
this.emit('state', {
isConnected,
...this.status,
})
}
setCensored(isCensored: boolean) {
assert(this.ws != null, 'Must be connected')
this.ws.send(JSON.stringify({ isCensored }))
}
setStreamRunning(isStreamRunning: boolean) {
assert(this.ws != null, 'Must be connected')
this.ws.send(JSON.stringify({ isStreamRunning }))
}
}

View File

@@ -0,0 +1,163 @@
import TOML from '@iarna/toml'
import { Repeater } from '@repeaterjs/repeater'
import { watch } from 'chokidar'
import { EventEmitter, once } from 'events'
import { promises as fsPromises } from 'fs'
import { isArray } from 'lodash-es'
import fetch from 'node-fetch'
import { promisify } from 'util'
import { StreamData, StreamList } from '../../../streamwall-shared/src/types'
const sleep = promisify(setTimeout)
type DataSource = AsyncGenerator<StreamData[]>
export async function* pollDataURL(url: string, intervalSecs: number) {
const refreshInterval = intervalSecs * 1000
let lastData = []
while (true) {
let data: StreamData[] = []
try {
const resp = await fetch(url)
data = (await resp.json()) as StreamData[]
} catch (err) {
console.warn('error loading stream data', err)
}
// If the endpoint errors or returns an empty dataset, keep the cached data.
if (!data.length && lastData.length) {
console.warn('using cached stream data')
} else {
yield data
lastData = data
}
await sleep(refreshInterval)
}
}
export async function* watchDataFile(path: string): DataSource {
const watcher = watch(path)
while (true) {
let data
try {
const text = await fsPromises.readFile(path)
data = TOML.parse(text.toString())
} catch (err) {
console.warn('error reading data file', err)
}
if (data && isArray(data.streams)) {
// TODO: type validate with Zod
yield data.streams as unknown as StreamList
} else {
yield []
}
await once(watcher, 'change')
}
}
export async function* markDataSource(dataSource: DataSource, name: string) {
for await (const streamList of dataSource) {
for (const s of streamList) {
s._dataSource = name
}
yield streamList
}
}
export async function* combineDataSources(dataSources: DataSource[]) {
for await (const streamLists of Repeater.latest(dataSources)) {
const dataByURL = new Map<string, StreamData>()
for (const list of streamLists) {
for (const data of list) {
const existing = dataByURL.get(data.link)
dataByURL.set(data.link, { ...existing, ...data })
}
}
const streams: StreamList = [...dataByURL.values()]
// Retain the index to speed up local lookups
streams.byURL = dataByURL
yield streams
}
}
export class LocalStreamData extends EventEmitter {
dataByURL: Map<string, Partial<StreamData>>
constructor() {
super()
this.dataByURL = new Map()
}
update(url: string, data: Partial<StreamData>) {
const existing = this.dataByURL.get(url)
this.dataByURL.set(data.link ?? url, { ...existing, ...data, link: url })
if (data.link != null && url !== data.link) {
this.dataByURL.delete(url)
}
this._emitUpdate()
}
delete(url: string) {
this.dataByURL.delete(url)
this._emitUpdate()
}
_emitUpdate() {
this.emit('update', [...this.dataByURL.values()])
}
gen(): AsyncGenerator<StreamData[]> {
return new Repeater(async (push, stop) => {
await push([])
this.on('update', push)
await stop
this.off('update', push)
})
}
}
export class StreamIDGenerator {
idMap: Map<string, string>
idSet: Set<string>
constructor() {
this.idMap = new Map()
this.idSet = new Set()
}
process(streams: StreamData[]) {
const { idMap, idSet } = this
for (const stream of streams) {
const { link, source, label } = stream
let streamId = idMap.get(link)
if (streamId == null) {
let counter = 0
let newId
const idBase = source || label || link
if (!idBase) {
console.warn('skipping empty stream', stream)
continue
}
const normalizedText = idBase
.toLowerCase()
.replace(/[^\w]/g, '')
.replace(/^the|^https?(www)?/, '')
do {
const textPart = normalizedText.substr(0, 3).toLowerCase()
const counterPart = counter === 0 && textPart ? '' : counter
newId = `${textPart}${counterPart}`
counter++
} while (idSet.has(newId))
streamId = newId
idMap.set(link, streamId)
idSet.add(streamId)
}
stream._id = streamId
}
return streams
}
}

View File

@@ -0,0 +1,477 @@
import TOML from '@iarna/toml'
import * as Sentry from '@sentry/electron/main'
import { BrowserWindow, app, session } from 'electron'
import started from 'electron-squirrel-startup'
import fs from 'fs'
import 'source-map-support/register'
import { ControlCommand, StreamwallState } from 'streamwall-shared'
import { updateElectronApp } from 'update-electron-app'
import yargs from 'yargs'
import * as Y from 'yjs'
import { ensureValidURL } from '../util'
import ControlWindow from './ControlWindow'
import {
LocalStreamData,
StreamIDGenerator,
combineDataSources,
markDataSource,
pollDataURL,
watchDataFile,
} from './data'
import StreamdelayClient from './StreamdelayClient'
import StreamWindow from './StreamWindow'
const SENTRY_DSN =
'https://e630a21dcf854d1a9eb2a7a8584cbd0b@o459879.ingest.sentry.io/5459505'
export interface StreamwallConfig {
help: boolean
grid: {
count: number
}
window: {
x?: number
y?: number
width: number
height: number
frameless: boolean
'background-color': string
'active-color': string
}
data: {
interval: number
'json-url': string[]
'toml-file': string[]
}
streamdelay: {
endpoint: string
key: string | null
}
telemetry: {
sentry: boolean
}
}
function parseArgs(): StreamwallConfig {
return (
yargs()
.config('config', (configPath) => {
return TOML.parse(fs.readFileSync(configPath, 'utf-8'))
})
.group(['grid.count'], 'Grid dimensions')
.option('grid.count', {
number: true,
default: 3,
})
.group(
[
'window.width',
'window.height',
'window.x',
'window.y',
'window.frameless',
'window.background-color',
'window.active-color',
],
'Window settings',
)
.option('window.x', {
number: true,
})
.option('window.y', {
number: true,
})
.option('window.width', {
number: true,
default: 1920,
})
.option('window.height', {
number: true,
default: 1080,
})
.option('window.frameless', {
boolean: true,
default: false,
})
.option('window.background-color', {
describe: 'Background color of wall (useful for chroma-keying)',
default: '#000',
})
.option('window.active-color', {
describe: 'Active (highlight) color of wall',
default: '#fff',
})
.group(
['data.interval', 'data.json-url', 'data.toml-file'],
'Datasources',
)
.option('data.interval', {
describe: 'Interval (in seconds) for refreshing polled data sources',
number: true,
default: 30,
})
.option('data.json-url', {
describe: 'Fetch streams from the specified URL(s)',
array: true,
string: true,
default: [],
})
.option('data.toml-file', {
describe: 'Fetch streams from the specified file(s)',
normalize: true,
array: true,
default: [],
})
/*
.group(
[
'control.username',
'control.password',
'control.address',
'control.hostname',
'control.port',
'control.open',
],
'Control Webserver',
)
.option('control.username', {
describe: 'Web control server username',
})
.option('control.password', {
describe: 'Web control server password',
})
.option('control.open', {
describe: 'After launching, open the control website in a browser',
boolean: true,
default: true,
})
.option('control.address', {
describe: 'Enable control webserver and specify the URL',
implies: ['control.username', 'control.password'],
string: true,
})
.option('control.hostname', {
describe: 'Override hostname the control server listens on',
})
.option('control.port', {
describe: 'Override port the control server listens on',
number: true,
})
.group(
['cert.dir', 'cert.production', 'cert.email'],
'Automatic SSL Certificate',
)
.option('cert.dir', {
describe: 'Private directory to store SSL certificate in',
implies: ['email'],
default: null,
})
.option('cert.production', {
describe: 'Obtain a real SSL certificate using production servers',
})
.option('cert.email', {
describe: 'Email for owner of SSL certificate',
})
*/
.group(['streamdelay.endpoint', 'streamdelay.key'], 'Streamdelay')
.option('streamdelay.endpoint', {
describe: 'URL of Streamdelay endpoint',
default: 'http://localhost:8404',
})
.option('streamdelay.key', {
describe: 'Streamdelay API key',
default: null,
})
.group(['telemetry.sentry'], 'Telemetry')
.option('telemetry.sentry', {
describe: 'Enable error reporting to Sentry',
boolean: true,
default: true,
})
.help()
// https://github.com/yargs/yargs/issues/2137
.parseSync() as unknown as StreamwallConfig
)
}
async function main(argv: ReturnType<typeof parseArgs>) {
// Reject all permission requests from web content.
session
.fromPartition('persist:session')
.setPermissionRequestHandler((webContents, permission, callback) => {
callback(false)
})
console.debug('Creating StreamWindow...')
const idGen = new StreamIDGenerator()
const localStreamData = new LocalStreamData()
const overlayStreamData = new LocalStreamData()
const streamWindowConfig = {
gridCount: argv.grid.count,
width: argv.window.width,
height: argv.window.height,
x: argv.window.x,
y: argv.window.y,
frameless: argv.window.frameless,
activeColor: argv.window['active-color'],
backgroundColor: argv.window['background-color'],
}
const streamWindow = new StreamWindow(streamWindowConfig)
const controlWindow = new ControlWindow()
let browseWindow: BrowserWindow | null = null
let streamdelayClient: StreamdelayClient | null = null
console.debug('Creating initial state...')
let clientState: StreamwallState = {
config: streamWindowConfig,
streams: [],
views: [],
streamdelay: null,
}
const stateDoc = new Y.Doc()
const viewsState = stateDoc.getMap<Y.Map<string | undefined>>('views')
stateDoc.transact(() => {
for (let i = 0; i < argv.grid.count ** 2; i++) {
const data = new Y.Map<string | undefined>()
data.set('streamId', undefined)
viewsState.set(String(i), data)
}
})
viewsState.observeDeep(() => {
try {
const viewContentMap = new Map()
for (const [key, viewData] of viewsState) {
const streamId = viewData.get('streamId')
const stream = clientState.streams.find((s) => s._id === streamId)
if (!stream) {
continue
}
viewContentMap.set(key, {
url: stream.link,
kind: stream.kind || 'video',
})
}
streamWindow.setViews(viewContentMap, clientState.streams)
} catch (err) {
console.error('Error updating views', err)
}
})
const onCommand = async (msg: ControlCommand) => {
console.debug('Received message:', msg)
if (msg.type === 'set-listening-view') {
console.debug('Setting listening view:', msg.viewIdx)
streamWindow.setListeningView(msg.viewIdx)
} else if (msg.type === 'set-view-background-listening') {
console.debug(
'Setting view background listening:',
msg.viewIdx,
msg.listening,
)
streamWindow.setViewBackgroundListening(msg.viewIdx, msg.listening)
} else if (msg.type === 'set-view-blurred') {
console.debug('Setting view blurred:', msg.viewIdx, msg.blurred)
streamWindow.setViewBlurred(msg.viewIdx, msg.blurred)
} else if (msg.type === 'rotate-stream') {
console.debug('Rotating stream:', msg.url, msg.rotation)
overlayStreamData.update(msg.url, {
rotation: msg.rotation,
})
} else if (msg.type === 'update-custom-stream') {
console.debug('Updating custom stream:', msg.url)
localStreamData.update(msg.url, msg.data)
} else if (msg.type === 'delete-custom-stream') {
console.debug('Deleting custom stream:', msg.url)
localStreamData.delete(msg.url)
} else if (msg.type === 'reload-view') {
console.debug('Reloading view:', msg.viewIdx)
streamWindow.reloadView(msg.viewIdx)
} else if (msg.type === 'browse' || msg.type === 'dev-tools') {
if (
msg.type === 'dev-tools' &&
browseWindow &&
!browseWindow.isDestroyed()
) {
// DevTools needs a fresh webContents to work. Close any existing window.
browseWindow.destroy()
browseWindow = null
}
if (!browseWindow || browseWindow.isDestroyed()) {
browseWindow = new BrowserWindow({
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
partition: 'persist:session',
sandbox: true,
},
})
}
if (msg.type === 'browse') {
console.debug('Attempting to browse URL:', msg.url)
try {
ensureValidURL(msg.url)
browseWindow.loadURL(msg.url)
} catch (error) {
console.error('Invalid URL:', msg.url)
console.error('Error:', error)
}
} else if (msg.type === 'dev-tools') {
console.debug('Opening DevTools for view:', msg.viewIdx)
streamWindow.openDevTools(msg.viewIdx, browseWindow.webContents)
}
} else if (msg.type === 'set-stream-censored' && streamdelayClient) {
console.debug('Setting stream censored:', msg.isCensored)
streamdelayClient.setCensored(msg.isCensored)
} else if (msg.type === 'set-stream-running' && streamdelayClient) {
console.debug('Setting stream running:', msg.isStreamRunning)
streamdelayClient.setStreamRunning(msg.isStreamRunning)
// TODO: Move to control server
/*} else if (msg.type === 'create-invite') {
console.debug('Creating invite for role:', msg.role)
const { secret } = await auth.createToken({
kind: 'invite',
role: msg.role,
name: msg.name,
})
respond({ name: msg.name, secret })
} else if (msg.type === 'delete-token') {
console.debug('Deleting token:', msg.tokenId)
auth.deleteToken(msg.tokenId)
*/
}
}
function updateState(newState: Partial<StreamwallState>) {
clientState = { ...clientState, ...newState }
streamWindow.onState(clientState)
controlWindow.onState(clientState)
}
// Wire up IPC:
// StreamWindow view updates -> main
streamWindow.on('state', (viewStates) => {
updateState({ views: viewStates })
})
// StreamWindow <- main init state
streamWindow.on('load', () => {
streamWindow.onState(clientState)
})
// Control <- main collab updates
stateDoc.on('update', (update) => {
controlWindow.onYDocUpdate(update)
})
// Control <- main init state
controlWindow.on('load', () => {
controlWindow.onState(clientState)
controlWindow.onYDocUpdate(Y.encodeStateAsUpdate(stateDoc))
})
// Control -> main
controlWindow.on('ydoc', (update) => Y.applyUpdate(stateDoc, update))
controlWindow.on('command', (command) => onCommand(command))
// TODO: Hide on macOS, allow reopening from dock
streamWindow.on('close', () => {
process.exit(0)
})
if (argv.streamdelay.key) {
console.debug('Setting up Streamdelay client...')
streamdelayClient = new StreamdelayClient({
endpoint: argv.streamdelay.endpoint,
key: argv.streamdelay.key,
})
streamdelayClient.on('state', (state) => {
updateState({ streamdelay: state })
})
streamdelayClient.connect()
}
/*
if (argv.control.address) {
console.debug('Initializing web server...')
const webDistPath = path.join(app.getAppPath(), 'web')
await initWebServer({
certDir: argv.cert.dir,
certProduction: argv.cert.production,
email: argv.cert.email,
url: argv.control.address,
hostname: argv.control.hostname,
port: argv.control.port,
logEnabled: true,
webDistPath,
auth,
clientState,
onMessage,
stateDoc,
})
if (argv.control.open) {
shell.openExternal(argv.control.address)
}
}
*/
const dataSources = [
...argv.data['json-url'].map((url) => {
console.debug('Setting data source from json-url:', url)
return markDataSource(pollDataURL(url, argv.data.interval), 'json-url')
}),
...argv.data['toml-file'].map((path) => {
console.debug('Setting data source from toml-file:', path)
return markDataSource(watchDataFile(path), 'toml-file')
}),
markDataSource(localStreamData.gen(), 'custom'),
overlayStreamData.gen(),
]
for await (const rawStreams of combineDataSources(dataSources)) {
console.debug('Processing streams:', rawStreams)
const streams = idGen.process(rawStreams)
updateState({ streams })
}
}
function init() {
console.debug('Parsing command line arguments...')
const argv = parseArgs()
if (argv.help) {
return
}
console.debug('Initializing Sentry...')
if (argv.telemetry.sentry) {
Sentry.init({ dsn: SENTRY_DSN })
}
updateElectronApp()
console.debug('Setting up Electron...')
app.commandLine.appendSwitch('high-dpi-support', '1')
app.commandLine.appendSwitch('force-device-scale-factor', '1')
console.debug('Enabling Electron sandbox...')
app.enableSandbox()
app
.whenReady()
.then(() => main(argv))
.catch((err) => {
console.error(err)
process.exit(1)
})
}
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (started) {
app.quit()
}
console.debug('Starting Streamwall...')
init()

View File

@@ -0,0 +1,24 @@
import { WebContents } from 'electron'
import path from 'path'
import querystring from 'querystring'
export function loadHTML(
webContents: WebContents,
name: 'background' | 'overlay' | 'playHLS' | 'control',
options?: { query?: Record<string, string> },
) {
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
const queryString = options?.query
? '?' + querystring.stringify(options.query)
: ''
webContents.loadURL(
`${MAIN_WINDOW_VITE_DEV_SERVER_URL}/src/renderer/${name}.html` +
queryString,
)
} else {
webContents.loadFile(
path.join(__dirname, `../renderer/${MAIN_WINDOW_VITE_NAME}/${name}.html`),
options,
)
}
}

View File

@@ -0,0 +1,330 @@
import assert from 'assert'
import {
BrowserWindow,
Rectangle,
WebContents,
WebContentsView,
} from 'electron'
import { isEqual } from 'lodash-es'
import { ViewContent, ViewPos } from 'streamwall-shared'
import {
ContentDisplayOptions,
ContentViewInfo,
} from 'streamwall-shared/src/types'
import { Actor, assign, fromPromise, setup } from 'xstate'
import { ensureValidURL } from '../util'
import { loadHTML } from './loadHTML'
const viewStateMachine = setup({
types: {
input: {} as {
id: number
view: WebContentsView
win: BrowserWindow
offscreenWin: BrowserWindow
},
context: {} as {
id: number
win: BrowserWindow
offscreenWin: BrowserWindow
view: WebContentsView
pos: ViewPos | null
content: ViewContent | null
options: ContentDisplayOptions | null
info: ContentViewInfo | null
},
events: {} as
| { type: 'OPTIONS'; options: ContentDisplayOptions }
| {
type: 'DISPLAY'
pos: ViewPos
content: ViewContent
}
| { type: 'VIEW_INIT' }
| { type: 'VIEW_LOADED' }
| { type: 'VIEW_INFO'; info: ContentViewInfo }
| { type: 'VIEW_ERROR'; error: unknown }
| { type: 'MUTE' }
| { type: 'UNMUTE' }
| { type: 'BACKGROUND' }
| { type: 'UNBACKGROUND' }
| { type: 'BLUR' }
| { type: 'UNBLUR' }
| { type: 'RELOAD' }
| { type: 'DEVTOOLS'; inWebContents: WebContents },
},
actions: {
logError: (_, params: { error: unknown }) => {
console.warn(params.error)
},
muteAudio: ({ context }) => {
context.view.webContents.audioMuted = true
},
unmuteAudio: ({ context }) => {
context.view.webContents.audioMuted = false
},
openDevTools: ({ context }, params: { inWebContents: WebContents }) => {
const { view } = context
const { inWebContents } = params
view.webContents.setDevToolsWebContents(inWebContents)
view.webContents.openDevTools({ mode: 'detach' })
},
sendViewOptions: (
{ context },
params: { options: ContentDisplayOptions },
) => {
const { view } = context
view.webContents.send('options', params.options)
},
offscreenView: ({ context }) => {
const { view, win, offscreenWin } = context
// It appears necessary to initialize the browser view by adding it to a window and setting bounds. Otherwise, some streaming sites like Periscope will not load their videos due to RAFs not firing.
// TODO: Is this still necessary with WebContentsView?
win.contentView.removeChildView(view)
offscreenWin.contentView.addChildView(view)
view.setBounds(win.getBounds())
},
positionView: ({ context }) => {
const { pos, view, win, offscreenWin } = context
if (!pos) {
return
}
offscreenWin.contentView.removeChildView(view)
win.contentView.addChildView(view, 1) // Insert at index 1 above default view and so overlay remains on top
view.setBounds(pos)
},
},
guards: {
contentUnchanged: ({ context }, params: { content: ViewContent }) => {
return isEqual(context.content, params.content)
},
contentPosUnchanged: (
{ context },
params: { content: ViewContent; pos: Rectangle },
) => {
return (
isEqual(context.content, params.content) &&
isEqual(context.pos, params.pos)
)
},
optionsChanged: (
{ context },
params: { options: ContentDisplayOptions },
) => {
return !isEqual(context.options, params.options)
},
},
actors: {
loadPage: fromPromise(
async ({
input: { content, view },
}: {
input: { content: ViewContent | null; view: WebContentsView }
}) => {
assert(content !== null)
ensureValidURL(content.url)
const wc = view.webContents
wc.audioMuted = true
if (/\.m3u8?$/.test(content.url)) {
loadHTML(wc, 'playHLS', { query: { src: content.url } })
} else {
wc.loadURL(content.url)
}
},
),
},
}).createMachine({
id: 'view',
initial: 'empty',
context: ({ input: { id, view, win, offscreenWin } }) => ({
id,
view,
win,
offscreenWin,
pos: null,
content: null,
options: null,
info: null,
}),
on: {
DISPLAY: {
target: '.displaying',
actions: assign({
pos: ({ event }) => event.pos,
content: ({ event }) => event.content,
}),
},
},
states: {
empty: {},
displaying: {
id: 'displaying',
initial: 'loading',
entry: 'offscreenView',
on: {
DISPLAY: {
actions: assign({
pos: ({ event }) => event.pos,
}),
guard: {
type: 'contentUnchanged',
params: ({ event: { content } }) => ({ content }),
},
},
OPTIONS: {
actions: [
assign({
options: ({ event }) => event.options,
}),
{
type: 'sendViewOptions',
params: ({ event: { options } }) => ({ options }),
},
],
guard: {
type: 'optionsChanged',
params: ({ event: { options } }) => ({ options }),
},
},
RELOAD: '.loading',
DEVTOOLS: {
actions: {
type: 'openDevTools',
params: ({ event: { inWebContents } }) => ({ inWebContents }),
},
},
VIEW_ERROR: {
target: '.error',
actions: {
type: 'logError',
params: ({ event: { error } }) => ({ error }),
},
},
VIEW_INFO: {
actions: assign({
info: ({ event }) => event.info,
}),
},
},
states: {
loading: {
initial: 'navigate',
states: {
navigate: {
invoke: {
src: 'loadPage',
input: ({ context: { content, view } }) => ({ content, view }),
onDone: {
target: 'waitForInit',
},
onError: {
target: '#view.displaying.error',
actions: {
type: 'logError',
params: ({ event: { error } }) => ({ error }),
},
},
},
},
waitForInit: {
on: {
VIEW_INIT: 'waitForVideo',
},
},
waitForVideo: {
on: {
VIEW_LOADED: '#view.displaying.running',
},
},
},
},
running: {
type: 'parallel',
entry: 'positionView',
on: {
DISPLAY: [
// Noop if nothing changed.
{
guard: {
type: 'contentPosUnchanged',
params: ({ event: { content, pos } }) => ({ content, pos }),
},
},
{
actions: [
assign({
pos: ({ event }) => event.pos,
}),
'positionView',
],
guard: {
type: 'contentUnchanged',
params: ({ event: { content } }) => ({ content }),
},
},
],
},
states: {
audio: {
initial: 'muted',
on: {
MUTE: '.muted',
UNMUTE: '.listening',
BACKGROUND: '.background',
UNBACKGROUND: '.muted',
},
states: {
muted: {
entry: 'muteAudio',
},
listening: {
entry: 'unmuteAudio',
},
background: {
on: {
// Ignore normal audio swapping.
MUTE: {},
},
entry: 'unmuteAudio',
},
},
},
video: {
initial: 'normal',
on: {
BLUR: '.blurred',
UNBLUR: '.normal',
},
states: {
normal: {},
blurred: {},
},
},
},
},
error: {},
},
},
},
})
export type ViewActor = Actor<typeof viewStateMachine>
export default viewStateMachine

View File

@@ -0,0 +1,30 @@
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'
import { StreamwallState } from 'streamwall-shared'
const api = {
load: () => ipcRenderer.invoke('control:load'),
openDevTools: () => ipcRenderer.invoke('control:devtools'),
invokeCommand: (msg: object) => ipcRenderer.invoke('control:command', msg),
updateYDoc: (update: Uint8Array) =>
ipcRenderer.invoke('control:ydoc', update),
onState: (handleState: (state: StreamwallState) => void) => {
const internalHandler = (_ev: IpcRendererEvent, state: StreamwallState) =>
handleState(state)
ipcRenderer.on('state', internalHandler)
return () => {
ipcRenderer.off('state', internalHandler)
}
},
onYDoc: (handleUpdate: (update: Uint8Array) => void) => {
const internalHandler = (_ev: IpcRendererEvent, update: Uint8Array) =>
handleUpdate(update)
ipcRenderer.on('ydoc', internalHandler)
return () => {
ipcRenderer.off('ydoc', internalHandler)
}
},
}
export type StreamwallControlGlobal = typeof api
contextBridge.exposeInMainWorld('streamwallControl', api)

View File

@@ -0,0 +1,19 @@
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron'
import { StreamwallState } from 'streamwall-shared'
const api = {
openDevTools: () => ipcRenderer.send('devtools-overlay'),
load: () => ipcRenderer.invoke('layer:load'),
onState: (handleState: (state: StreamwallState) => void) => {
const internalHandler = (_ev: IpcRendererEvent, state: StreamwallState) =>
handleState(state)
ipcRenderer.on('state', internalHandler)
return () => {
ipcRenderer.off('state', internalHandler)
}
},
}
export type StreamwallLayerGlobal = typeof api
contextBridge.exposeInMainWorld('streamwallLayer', api)

View File

@@ -0,0 +1,270 @@
import { ipcRenderer, webFrame } from 'electron'
import throttle from 'lodash/throttle'
import { ContentDisplayOptions } from 'streamwall-shared'
const SCAN_THROTTLE = 500
const VIDEO_OVERRIDE_STYLE = `
* {
pointer-events: none;
display: none !important;
position: static !important;
z-index: 0 !important;
}
html, body, video, audio {
display: block !important;
background: black !important;
}
html, body {
overflow: hidden !important;
background: black !important;
}
video, iframe.__video__, audio {
display: block !important;
position: fixed !important;
left: 0 !important;
right: 0 !important;
top: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
object-fit: cover !important;
transition: none !important;
z-index: 999999 !important;
}
audio {
z-index: 999998 !important;
}
.__video_parent__ {
display: block !important;
}
video.__rot180__ {
transform: rotate(180deg) !important;
}
/* For 90 degree rotations, we position the video with swapped width and height and rotate it into place.
It's helpful to offset the video so the transformation is centered in the viewport center.
We move the video top left corner to center of the page and then translate half the video dimensions up and left.
Note that the width and height are swapped in the translate because the video starts with the side dimensions swapped. */
video.__rot90__ {
transform: translate(-50vh, -50vw) rotate(90deg) !important;
}
video.__rot270__ {
transform: translate(-50vh, -50vw) rotate(270deg) !important;
}
video.__rot90__, video.__rot270__ {
left: 50vw !important;
top: 50vh !important;
width: 100vh !important;
height: 100vw !important;
}
`
const NO_SCROLL_STYLE = `
html, body {
overflow: hidden !important;
}
`
const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(() => resolve(), ms))
const pageReady = new Promise((resolve) =>
document.addEventListener('DOMContentLoaded', resolve, { once: true }),
)
class RotationController {
video: HTMLVideoElement
siteRotation: number
customRotation: number
constructor(video: HTMLVideoElement) {
this.video = video
this.siteRotation = 0
this.customRotation = 0
}
_update() {
const rotation = (this.siteRotation + this.customRotation) % 360
if (![0, 90, 180, 270].includes(rotation)) {
console.warn('ignoring invalid rotation', rotation)
}
this.video.className = `__rot${rotation}__`
}
setSite(rotation = 0) {
this.siteRotation = rotation
this._update()
}
setCustom(rotation = 0) {
this.customRotation = rotation
this._update()
}
}
// Watch for media tags and mute them as soon as possible.
async function lockdownMediaTags() {
const lockdown = throttle(() => {
webFrame.executeJavaScript(`
for (const el of document.querySelectorAll('video, audio')) {
if (el.__sw) {
continue
}
// Prevent sites from re-muting the video
Object.defineProperty(el, 'muted', { writable: true, value: false })
// Prevent Facebook from pausing the video after page load.
Object.defineProperty(el, 'pause', { writable: false, value: () => {} })
el.__sw = true
}
`)
}, SCAN_THROTTLE)
await pageReady
const observer = new MutationObserver(lockdown)
observer.observe(document.body, { subtree: true, childList: true })
}
async function waitForQuery(query: string): Promise<Element> {
console.log(`waiting for '${query}'...`)
await pageReady
return new Promise((resolve) => {
const scan = throttle(() => {
const el = document.querySelector(query)
if (el) {
console.log(`found '${query}'`)
resolve(el)
observer.disconnect()
}
}, SCAN_THROTTLE)
const observer = new MutationObserver(scan)
observer.observe(document.body, { subtree: true, childList: true })
scan()
})
}
async function waitForVideo(kind: 'video' | 'audio'): Promise<{
video?: HTMLMediaElement
iframe?: HTMLIFrameElement
}> {
lockdownMediaTags()
let video: Element | null | void = await Promise.race([
waitForQuery(kind),
sleep(10 * 1000),
])
if (video instanceof HTMLMediaElement) {
return { video }
}
let iframe
for (iframe of document.querySelectorAll('iframe')) {
video = iframe.contentDocument?.querySelector?.(kind)
if (video instanceof HTMLVideoElement) {
return { video, iframe }
}
}
return {}
}
const igHacks = {
isMatch() {
return location.host === 'www.instagram.com'
},
async onLoad() {
const playButton = await Promise.race([
waitForQuery('button'),
waitForQuery('video'),
sleep(1000),
])
if (
playButton instanceof HTMLButtonElement &&
playButton.tagName === 'BUTTON' &&
playButton.textContent === 'Tap to play'
) {
playButton.click()
}
},
}
async function findVideo(kind: 'video' | 'audio') {
if (igHacks.isMatch()) {
await igHacks.onLoad()
}
const { video, iframe } = await waitForVideo(kind)
if (!video) {
throw new Error('could not find video')
}
if (iframe && iframe.contentDocument) {
// TODO: verify iframe still works
const style = iframe.contentDocument.createElement('style')
style.innerHTML = VIDEO_OVERRIDE_STYLE
iframe.contentDocument.head.appendChild(style)
iframe.className = '__video__'
let parentEl = iframe.parentElement
while (parentEl) {
parentEl.className = '__video_parent__'
parentEl = parentEl.parentElement
}
iframe.contentDocument.body.appendChild(video)
} else {
document.body.appendChild(video)
}
video.play()
if (video instanceof HTMLVideoElement && !video.videoWidth) {
console.log(`video isn't playing yet. waiting for it to start...`)
const videoReady = new Promise((resolve) =>
video.addEventListener('playing', resolve, { once: true }),
)
await Promise.race([videoReady, sleep(10 * 1000)])
if (!video.videoWidth) {
throw new Error('timeout waiting for video to start')
}
console.log('video started')
}
video.muted = false
const info = {
title: document.title,
}
return { info, video }
}
async function main() {
const viewInit = ipcRenderer.invoke('view-init')
const pageReady = new Promise((resolve) => process.once('loaded', resolve))
const [{ content, options: initialOptions }] = await Promise.all([
viewInit,
pageReady,
])
let rotationController: RotationController | undefined
if (content.kind === 'video' || content.kind === 'audio') {
webFrame.insertCSS(VIDEO_OVERRIDE_STYLE, { cssOrigin: 'user' })
const { info, video } = await findVideo(content.kind)
if (content.kind === 'video' && video instanceof HTMLVideoElement) {
rotationController = new RotationController(video)
}
ipcRenderer.send('view-info', { info })
} else if (content.kind === 'web') {
webFrame.insertCSS(NO_SCROLL_STYLE, { cssOrigin: 'user' })
}
ipcRenderer.send('view-loaded')
function updateOptions(options: ContentDisplayOptions) {
if (rotationController) {
rotationController.setCustom(options.rotation)
}
}
ipcRenderer.on('options', (ev, options) => updateOptions(options))
updateOptions(initialOptions)
}
main().catch((error) => {
ipcRenderer.send('view-error', { error })
})

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Streamwall Stream Background</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'self' 'unsafe-inline'; frame-src *"
/>
</head>
<body>
<script src="background.tsx" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,55 @@
import '@fontsource/noto-sans'
import './index.css'
import { render } from 'preact'
import { useEffect, useState } from 'preact/hooks'
import { styled } from 'styled-components'
import { StreamData, StreamList } from '../../../streamwall-shared/src/types'
import { StreamwallLayerGlobal } from '../preload/layerPreload'
declare global {
interface Window {
streamwall: StreamwallLayerGlobal
}
}
function Background({ streams }: { streams: StreamList }) {
const backgrounds = streams.filter((s) => s.kind === 'background')
return (
<div>
{backgrounds.map((s) => (
<BackgroundIFrame
key={s._id}
src={s.link}
sandbox="allow-scripts allow-same-origin"
allow="autoplay"
/>
))}
</div>
)
}
function App() {
const [streams, setStreams] = useState<StreamData[]>([])
useEffect(() => {
const unsubscribe = window.streamwallLayer.onState(({ streams }) =>
setStreams(streams),
)
window.streamwallLayer.load()
return unsubscribe
}, [])
return <Background streams={streams} />
}
const BackgroundIFrame = styled.iframe`
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
border: none;
`
render(<App />, document.body)

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Streamwall Control</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'self' 'unsafe-inline'"
/>
</head>
<body>
<script src="control.tsx" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,98 @@
import '@fontsource/noto-sans'
import './index.css'
import { render } from 'preact'
import { useCallback, useEffect, useState } from 'preact/hooks'
import { useHotkeys } from 'react-hotkeys-hook'
import {
CollabData,
ControlUI,
GlobalStyle,
StreamwallConnection,
useStreamwallState,
useYDoc,
} from 'streamwall-control-ui'
import { ControlCommand, StreamwallState } from 'streamwall-shared'
import * as Y from 'yjs'
import { StreamwallControlGlobal } from '../preload/controlPreload'
declare global {
interface Window {
streamwallControl: StreamwallControlGlobal
}
}
function useStreamwallIPCConnection(): StreamwallConnection {
const { docValue: sharedState, doc: stateDoc } = useYDoc<CollabData>([
'views',
])
const [streamwallState, setStreamwallState] = useState<StreamwallState>()
const appState = useStreamwallState(streamwallState)
useEffect(() => {
// TODO: improve typing (Zod?)
function handleState(state: StreamwallState) {
setStreamwallState(state)
}
return window.streamwallControl.onState(handleState)
}, [])
const send = useCallback(
async (msg: ControlCommand, cb?: (msg: unknown) => void) => {
const resp = await window.streamwallControl.invokeCommand(msg)
cb?.(resp)
},
[],
)
useEffect(() => {
function sendUpdate(update: Uint8Array, origin: string) {
if (origin === 'app') {
return
}
window.streamwallControl.updateYDoc(update)
}
function handleUpdate(update: Uint8Array) {
Y.applyUpdate(stateDoc, update, 'app')
}
stateDoc.on('update', sendUpdate)
const unsubscribeUpdate = window.streamwallControl.onYDoc(handleUpdate)
return () => {
stateDoc.off('update', sendUpdate)
unsubscribeUpdate()
}
}, [stateDoc])
useEffect(() => {
window.streamwallControl.load()
}, [])
return {
...appState,
isConnected: true,
role: 'local',
send,
sharedState,
stateDoc,
}
}
function App() {
const connection = useStreamwallIPCConnection()
useHotkeys('ctrl+shift+i', () => {
window.streamwallControl.openDevTools()
})
return (
<>
<GlobalStyle />
<ControlUI connection={connection} />
</>
)
}
render(<App />, document.body)

View File

@@ -0,0 +1,3 @@
body {
font-family: 'Noto Sans';
}

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Streamwall Stream Overlay</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; style-src 'self' 'unsafe-inline'; frame-src *"
/>
</head>
<body>
<script src="overlay.tsx" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,266 @@
import Color from 'color'
import { render } from 'preact'
import { useEffect, useState } from 'preact/hooks'
import { useHotkeys } from 'react-hotkeys-hook'
import {
FaFacebook,
FaInstagram,
FaTiktok,
FaTwitch,
FaVolumeUp,
FaYoutube,
} from 'react-icons/fa'
import { StreamwallState } from 'streamwall-shared'
import { styled } from 'styled-components'
import { TailSpin } from 'svg-loaders-react'
import { matchesState } from 'xstate'
import { StreamwallLayerGlobal } from '../preload/layerPreload'
import '@fontsource/noto-sans'
import './index.css'
declare global {
interface Window {
streamwallLayer: StreamwallLayerGlobal
}
}
function Overlay({
config,
views,
streams,
}: Pick<StreamwallState, 'config' | 'views' | 'streams'>) {
const { width, height, activeColor } = config
const activeViews = views.filter(
({ state }) =>
matchesState('displaying', state) &&
!matchesState('displaying.error', state),
)
const overlays = streams.filter((s) => s.kind === 'overlay')
return (
<div>
{activeViews.map(({ state, context }) => {
const { content, pos } = context
if (!content) {
return
}
const data = streams.find((d) => content.url === d.link)
const isListening = matchesState(
'displaying.running.audio.listening',
state,
)
const isBackgroundListening = matchesState(
'displaying.running.audio.background',
state,
)
const isBlurred = matchesState(
'displaying.running.video.blurred',
state,
)
const isLoading = matchesState('displaying.loading', state)
const hasTitle = data && (data.label || data.source)
const position = data?.labelPosition ?? 'top-left'
return (
<SpaceBorder
pos={pos}
windowWidth={width}
windowHeight={height}
activeColor={activeColor}
isListening={isListening}
>
<BlurCover isBlurred={isBlurred} />
{hasTitle && (
<StreamTitle
position={position}
activeColor={activeColor}
isListening={isListening}
>
<StreamIcon url={content.url} />
<span>{data.label}</span>
{(isListening || isBackgroundListening) && <FaVolumeUp />}
</StreamTitle>
)}
{isLoading && <LoadingSpinner />}
</SpaceBorder>
)
})}
{overlays.map((s) => (
<OverlayIFrame
key={s._id}
src={s.link}
sandbox="allow-scripts allow-same-origin"
allow="autoplay"
/>
))}
</div>
)
}
function App() {
const [state, setState] = useState<StreamwallState | undefined>()
useEffect(() => {
const unsubscribe = window.streamwallLayer.onState(setState)
window.streamwallLayer.load()
return unsubscribe
}, [])
useHotkeys('ctrl+shift+i', () => {
window.streamwallLayer.openDevTools()
})
if (!state) {
return
}
const { config, views, streams } = state
return <Overlay config={config} views={views} streams={streams} />
}
function StreamIcon({ url }: { url: string }) {
let parsedURL
try {
parsedURL = new URL(url)
} catch {
return null
}
let { host } = parsedURL
host = host.replace(/^www\./, '')
if (host === 'youtube.com' || host === 'youtu.be') {
return <FaYoutube />
} else if (host === 'facebook.com' || host === 'm.facebook.com') {
return <FaFacebook />
} else if (host === 'twitch.tv') {
return <FaTwitch />
} else if (host === 'instagram.com') {
return <FaInstagram />
} else if (host === 'tiktok.com') {
return <FaTiktok />
}
return null
}
const SpaceBorder = styled.div.attrs(() => ({
borderWidth: 2,
}))`
display: flex;
align-items: flex-start;
position: fixed;
left: ${({ pos }) => pos.x}px;
top: ${({ pos }) => pos.y}px;
width: ${({ pos }) => pos.width}px;
height: ${({ pos }) => pos.height}px;
border: 0 solid 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, activeColor }) =>
isListening ? `0 0 10px ${activeColor} inset` : 'none'};
box-sizing: border-box;
pointer-events: none;
user-select: none;
`
const StreamTitle = styled.div`
position: absolute;
${({ position }) => {
if (position === 'top-left') {
return `top: 0; left: 0;`
} else if (position === 'top-right') {
return `top: 0; right: 0;`
} else if (position === 'bottom-right') {
return `bottom: 0; right: 0;`
} else if (position === 'bottom-left') {
return `bottom: 0; left: 0;`
}
}}
max-width: calc(100% - 10px);
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px 10px;
margin: 5px;
font-weight: 600;
font-size: 20px;
color: white;
text-shadow: 0 0 4px black;
letter-spacing: -0.025em;
background: ${({
isListening,
activeColor,
}: {
isListening: boolean
activeColor: string
}) =>
Color(isListening ? activeColor : 'black')
.alpha(0.5)
.toString()};
border-radius: 4px;
backdrop-filter: blur(10px);
overflow: hidden;
span {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
svg {
width: 1.25em;
height: 1.25em;
overflow: visible;
filter: drop-shadow(0 0 4px black);
&:first-child {
margin-right: 0.35em;
}
&:last-child {
margin-left: 0.5em;
}
path {
fill: white;
}
}
`
const LoadingSpinner = styled(TailSpin)`
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 100px;
height: 100px;
opacity: 0.5;
`
const BlurCover = styled.div`
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
backdrop-filter: ${({ isBlurred }) => (isBlurred ? 'blur(30px)' : 'blur(0)')};
`
const OverlayIFrame = styled.iframe`
position: fixed;
left: 0;
top: 0;
width: 100vw;
height: 100vh;
border: none;
pointer-events: none;
`
render(<App />, document.body)

View File

@@ -0,0 +1,14 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
<title>Streamwall HLS Player</title>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; connect-src *; media-src * blob:"
/>
</head>
<body>
<script src="playHLS.ts" type="module"></script>
</body>
</html>

View File

@@ -0,0 +1,19 @@
import Hls from 'hls.js'
function loadHLS(src: string) {
const videoEl = document.createElement('video')
const hls = new Hls()
hls.attachMedia(videoEl)
hls.loadSource(src)
hls.on(Hls.Events.MANIFEST_PARSED, () => {
document.body.appendChild(videoEl)
})
}
const searchParams = new URLSearchParams(location.search)
const src = searchParams.get('src')
if (src) {
loadHLS(src)
}

View File

@@ -0,0 +1,5 @@
declare module 'svg-loaders-react' {
import { FC, SVGProps } from 'react'
export const TailSpin: FC<SVGProps<SVGSVGElement>>
}

View File

@@ -0,0 +1,6 @@
export function ensureValidURL(urlStr: string) {
const url = new URL(urlStr)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error(`rejecting attempt to load non-http URL '${urlStr}'`)
}
}

View File

@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2024",
"module": "commonjs",
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"noImplicitAny": true,
"strictNullChecks": true,
"sourceMap": true,
"jsx": "react-jsx",
"jsxImportSource": "preact",
"paths": {
"react": ["./node_modules/preact/compat/"],
"react-dom": ["./node_modules/preact/compat/"]
},
"lib": ["DOM.iterable"],
"baseUrl": ".",
"outDir": "dist",
"moduleResolution": "node",
"resolveJsonModule": true
}
}

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
// https://vitejs.dev/config
export default defineConfig({
build: {
sourcemap: true,
},
})

View File

@@ -0,0 +1,8 @@
import { defineConfig } from 'vite'
// https://vitejs.dev/config
export default defineConfig({
build: {
sourcemap: true,
},
})

View File

@@ -0,0 +1,34 @@
import preact from '@preact/preset-vite'
import { resolve } from 'path'
import { defineConfig } from 'vite'
// https://vitejs.dev/config
export default defineConfig({
build: {
sourcemap: true,
rollupOptions: {
input: {
background: resolve(__dirname, 'background.html'),
overlay: resolve(__dirname, 'overlay.html'),
playHLS: resolve(__dirname, 'playHLS.html'),
control: resolve(__dirname, 'control.html'),
},
output: {
entryFileNames: '[name].[format]',
},
},
},
resolve: {
alias: {
// Necessary for vite to watch the package dir
'streamwall-control-ui': resolve(__dirname, '../streamwall-control-ui'),
'streamwall-shared': resolve(__dirname, '../streamwall-shared'),
},
},
plugins: [
// FIXME: working around TS error: "Type 'Plugin<any>' is not assignable to type 'PluginOption'"
...(preact() as Plugin[]),
],
})