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

16
.eslintrc.json Normal file
View File

@@ -0,0 +1,16 @@
{
"env": {
"browser": true,
"es6": true,
"node": true
},
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended",
"plugin:import/recommended",
"plugin:import/electron",
"plugin:import/typescript"
],
"parser": "@typescript-eslint/parser"
}

1
.github/FUNDING.yml vendored
View File

@@ -1 +0,0 @@
ko_fi: streamwall

View File

@@ -1,18 +0,0 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 25
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 25

34
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
permissions:
contents: write
on:
workflow_dispatch:
jobs:
build:
strategy:
matrix:
os:
[
{ name: 'linux', image: 'ubuntu-latest' },
{ name: 'windows', image: 'windows-latest' },
{ name: 'macos', image: 'macos-latest' },
]
runs-on: ${{ matrix.os.image }}
steps:
- name: Github checkout
uses: actions/checkout@v4
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- name: Publish app
env:
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
run: npm run publish

View File

@@ -1,39 +0,0 @@
name: test
on:
pull_request:
push:
permissions:
contents: read
actions: read
checks: write
jobs:
jest-coverage:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18.x
cache: 'npm'
- name: Install Dependencies
run: npm install
- name: Run Tests
run: npm run test:ci
env:
CI: true
- name: Test Report
uses: dorny/test-reporter@v1
if: success() || failure() # run this step even if previous step failed
with:
name: Jest Tests # Name of the check run which will be created
path: junit*.xml # Path to test results
reporter: jest-junit # Format of test results

7
.gitignore vendored
View File

@@ -1,8 +1 @@
# Entire directories
coverage
dist
node_modules node_modules
reports
# Individual files
junit.xml

View File

@@ -1,4 +1,4 @@
Copyright 2020 Max Goodhart Copyright 2025 Max Goodhart
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

View File

@@ -1,33 +1,22 @@
# Streamwall # Streamwall
:construction: Early WIP release! :construction: :construction: Streamwall v2.0 is a work-in-progress :construction:
Goals for the v2 branch:
- TypeScript
- Use Electron Forge to distribute packaged releases
- Split out control server; refactor for local-only use without a webserver
---
Streamwall makes it easy to compose multiple livestreams into a mosaic, with source attributions and audio control. Streamwall makes it easy to compose multiple livestreams into a mosaic, with source attributions and audio control.
![Screenshot of Streamwall displaying a grid of streams](screenshot.png)
## How it works ## How it works
Under the hood, think of Streamwall as a specialized web browser for mosaicing video streams. It uses [Electron](https://www.electronjs.org) to create a grid of web browser views, loading the specified webpages into them. Once the page loads, Streamwall finds the `<video>` tag and reformats the page so that the video fills the space. This works for a wide variety of web pages without specialized scrapers. Under the hood, think of Streamwall as a specialized web browser for mosaicing video streams. It uses [Electron](https://www.electronjs.org) to create a grid of web browser views, loading the specified webpages into them. Once the page loads, Streamwall finds the `<video>` tag and reformats the page so that the video fills the space. This works for a wide variety of web pages without specialized scrapers.
## Prerequisites
1. Node.js and npm. Download the LTS release from here - https://nodejs.org/en/
## Setup
1. Download streamwall. You can use git, or download and unzip https://github.com/chromakode/streamwall/archive/main.zip
2. Open the streamwall directory in a console
- In Windows, the LTS install from nodejs.org will install a program called "Node.js command prompt." Open this program; Command Prompt and Powershell may not have the correct environment variables. Once it's open, change directories to where you extracted the file, e.g., `> cd c:\Users\<myname>\Downloads\streamwall\`
- On MacOS, you should be able to use the default system terminal or other terminals like iTerm2 as long as a sufficient version of Node is installed. With that open, change directories to where you extracted the file, e.g., `> cd ~/Downloads/streamwall`
3. Run the following command: `npm install`
## To Start Streamwall
1. Using a terminal/console window as described above, go to the streamwall directory, and run `npm run start-local`
2. This will open a black stream window and a browser window. The default username is "streamwall" and the default password is "local-dev".
3. Use the browser window to load or control streams. The initial list will be populated from https://woke.net/#streams
4. If you enter the same stream code in multiple cells, it will merge them together for a larger stream.
## Configuration ## Configuration
@@ -53,10 +42,6 @@ Streamwall can load stream data from both JSON APIs and TOML files. Data sources
npm start -- --data.json-url="https://your-site/api/streams.json" --data.toml-file="./streams.toml" npm start -- --data.json-url="https://your-site/api/streams.json" --data.toml-file="./streams.toml"
``` ```
## Twitch bot
Streamwall can announce the name and URL of streams to your Twitch channel as you focus their audio. Use [twitchtokengenerator.com](https://twitchtokengenerator.com/?scope=chat:read+chat:edit) to generate an OAuth token. See `example.config.toml` for all available options.
## Hotkeys ## Hotkeys
The following hotkeys are available with the "control" webpage focused: The following hotkeys are available with the "control" webpage focused:
@@ -66,21 +51,3 @@ The following hotkeys are available with the "control" webpage focused:
- **alt+s**: Select the currently focused stream box to be swapped - **alt+s**: Select the currently focused stream box to be swapped
- **alt+c**: Activate [Streamdelay](https://github.com/chromakode/streamdelay) censor mode - **alt+c**: Activate [Streamdelay](https://github.com/chromakode/streamdelay) censor mode
- **alt+shift+c**: Deactivate [Streamdelay](https://github.com/chromakode/streamdelay) censor mode - **alt+shift+c**: Deactivate [Streamdelay](https://github.com/chromakode/streamdelay) censor mode
## Troubleshooting
### Unexpected token errors during `npm install`
We've observed this occur in cases where file corruption is an issue. The fix has been to clear the npm cache, remove the streamwall directory, and start from scratch.
### The Streamwall Electron window only fits 2.5 tiles wide
Streamwall in its default settings needs enough screen space to display a 1920x1080 (1080p) window, with room for the titlebar. You can configure Streamwall to open a smaller window:
```
npm start -- --window.width=1024 --window.height=768
```
## Credits
SVG Icons are from Font Awesome by Dave Gandy - http://fontawesome.io

View File

@@ -1 +0,0 @@
module.exports = {};

View File

@@ -1,18 +0,0 @@
{
"presets": [
["@babel/preset-env", { "targets": { "node": "current" } }]
],
"plugins": [
"@babel/plugin-proposal-optional-chaining",
"@babel/plugin-proposal-nullish-coalescing-operator",
"babel-plugin-styled-components",
[
"@babel/plugin-transform-react-jsx",
{
"pragma": "h",
"pragmaFrag": "Fragment"
}
],
["@babel/plugin-proposal-decorators", { "legacy": false, "decoratorsBeforeExport": true }]
]
}

View File

@@ -1,21 +0,0 @@
module.exports = {
verbose: true,
moduleFileExtensions: ['js', 'jsx', 'json', 'node'],
moduleNameMapper: {
'\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$':
'<rootDir>/__mocks__/fileMock.js',
'\\.(css|less)$': 'identity-obj-proxy',
"^preact(/(.*)|$)": "preact$1"
},
transform: {
'^.+\\.jsx?$': 'babel-jest',
},
transformIgnorePatterns: [
'node_modules/(?!(jsondiffpatch)/)',
],
testPathIgnorePatterns: ['/node_modules/'],
coveragePathIgnorePatterns: ['/node_modules/'],
collectCoverage: true,
coverageReporters: ['json', 'lcov', 'text', 'clover'],
testEnvironment: 'jsdom'
};

19884
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,80 +1,12 @@
{ {
"name": "streamwall", "name": "streamwall",
"version": "0.0.1", "workspaces": [
"description": "View streams in a grid", "packages/streamwall",
"main": "src/index.js", "packages/streamwall-shared",
"scripts": { "packages/streamwall-control-ui"
"build": "webpack", ],
"prune": "rm -rf dist",
"start": "npm run build -- --stats=errors-only && electron dist",
"start-local": "npm run build -- --stats=errors-only && electron dist --control.address=http://localhost:4444 --control.username=streamwall --control.password=local-dev",
"start-dev": "npm run build -- --stats=verbose && electron dist --enable-logging --control.address=http://localhost:4444 --control.username=streamwall --control.password=local-dev",
"test": "jest",
"test:ci": "jest --ci --reporters=default --reporters=jest-junit --testPathIgnorePatterns=src/node/server.test.js --coverage"
},
"author": "Max Goodhart <c@chromakode.com>",
"license": "MIT",
"dependencies": {
"@iarna/toml": "^2.2.5",
"@repeaterjs/repeater": "^3.0.6",
"@sentry/electron": "^5.3.0",
"base-x": "^5.0.0",
"chokidar": "^3.6.0",
"color": "^4.2.3",
"dank-twitch-irc": "^4.3.0",
"ejs": "^3.1.10",
"electron": "^31.3.1",
"hls.js": "^1.5.14",
"jsondiffpatch": "^0.6.0",
"koa": "^2.15.3",
"koa-basic-auth": "^4.0.0",
"koa-easy-ws": "^2.1.0",
"koa-route": "^4.0.1",
"koa-static": "^5.0.0",
"koa-views": "^8.1.0",
"lodash": "^4.17.21",
"luxon": "^3.5.0",
"node-fetch": "^3.3.2",
"node-simple-cert": "0.0.1",
"preact": "^10.23.1",
"react-hotkeys-hook": "^4.5.0",
"reconnecting-websocket": "^4.4.0",
"styled-components": "^6.1.12",
"svg-loaders-react": "^2.2.1",
"webpack-dev-server": "^5.0.4",
"ws": "^8.18.0",
"xstate": "^4.37.1",
"yargs": "^17.7.2",
"yjs": "^13.6.18"
},
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.2", "prettier": "^3.4.2",
"@babel/plugin-proposal-decorators": "^7.24.7", "prettier-plugin-organize-imports": "^4.1.0"
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6", }
"@babel/plugin-proposal-optional-chaining": "^7.21.0",
"@babel/plugin-transform-react-jsx": "^7.25.2",
"@babel/preset-env": "^7.25.3",
"@svgr/webpack": "^8.1.0",
"babel-jest": "^29.7.0",
"babel-loader": "^9.1.3",
"babel-plugin-styled-components": "^2.1.4",
"bufferutil": "^4.0.8",
"copy-webpack-plugin": "^12.0.2",
"css-loader": "^7.1.2",
"file-loader": "^6.2.0",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-junit": "^16.0.0",
"npm-check-updates": "^17.0.6",
"prettier": "3.3.3",
"style-loader": "^4.0.0",
"supertest": "^7.0.0",
"utf-8-validate": "^6.0.4",
"webpack": "^5.93.0",
"webpack-cli": "^5.1.4"
},
"browserslist": [
"electron 9.0"
]
} }

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"
}
}

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

@@ -1,6 +1,6 @@
import Color from 'color' import Color from 'color'
export function hashText(text, range) { export function hashText(text: string, range: number) {
// DJBX33A-ish // DJBX33A-ish
// based on https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/hueHash.js#L16-L45 // based on https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/hueHash.js#L16-L45
let val = 0 let val = 0
@@ -22,7 +22,7 @@ export function hashText(text, range) {
return (val + range) % range return (val + range) % range
} }
export function idColor(id) { export function idColor(id: string) {
if (!id) { if (!id) {
return Color('white') return Color('white')
} }

View File

@@ -1,20 +1,44 @@
import isEqual from 'lodash/isEqual' import { Rectangle } from 'electron'
import { isEqual } from 'lodash-es'
import { ContentKind } from './types'
export function boxesFromViewContentMap(width, height, viewContentMap) { 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 boxes = []
const visited = new Set() const visited = new Set()
function isPosContent(x, y, content) { function isPosContent(
x: number,
y: number,
content: ViewContent | undefined,
) {
const checkIdx = width * y + x const checkIdx = width * y + x
return ( return (
!visited.has(checkIdx) && isEqual(viewContentMap.get(checkIdx), content) !visited.has(checkIdx) &&
isEqual(viewContentMap.get(String(checkIdx)), content)
) )
} }
function findLargestBox(x, y) { function findLargestBox(x: number, y: number) {
const idx = width * y + x const idx = width * y + x
const spaces = [idx] const spaces = [idx]
const content = viewContentMap.get(idx) const content = viewContentMap.get(String(idx))
let maxY let maxY
for (maxY = y + 1; maxY < height; maxY++) { for (maxY = y + 1; maxY < height; maxY++) {
@@ -45,7 +69,7 @@ export function boxesFromViewContentMap(width, height, viewContentMap) {
for (let y = 0; y < width; y++) { for (let y = 0; y < width; y++) {
for (let x = 0; x < height; x++) { for (let x = 0; x < height; x++) {
const idx = width * y + x const idx = width * y + x
if (visited.has(idx) || viewContentMap.get(idx) === undefined) { if (visited.has(idx) || viewContentMap.get(String(idx)) === undefined) {
continue continue
} }
@@ -60,15 +84,20 @@ export function boxesFromViewContentMap(width, height, viewContentMap) {
return boxes return boxes
} }
export function idxToCoords(gridCount, idx) { export function idxToCoords(gridCount: number, idx: number) {
const x = idx % gridCount const x = idx % gridCount
const y = Math.floor(idx / gridCount) const y = Math.floor(idx / gridCount)
return { x, y } return { x, y }
} }
export function idxInBox(gridCount, start, end, idx) { export function idxInBox(
let { x: startX, y: startY } = idxToCoords(gridCount, start) gridCount: number,
let { x: endX, y: endY } = idxToCoords(gridCount, end) 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 { x, y } = idxToCoords(gridCount, idx)
const lowX = Math.min(startX, endX) const lowX = Math.min(startX, endX)
const highX = Math.max(startX, endX) const highX = Math.max(startX, endX)

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

@@ -1,10 +1,22 @@
import assert from 'assert'
import EventEmitter from 'events' import EventEmitter from 'events'
import ReconnectingWebSocket from 'reconnecting-websocket'
import { StreamDelayStatus } from 'streamwall-shared'
import * as url from 'url' import * as url from 'url'
import WebSocket from 'ws' import WebSocket from 'ws'
import ReconnectingWebSocket from 'reconnecting-websocket'
export interface StreamdelayClientOptions {
endpoint: string
key: string
}
export default class StreamdelayClient extends EventEmitter { export default class StreamdelayClient extends EventEmitter {
constructor({ endpoint, key }) { endpoint: string
key: string
ws: ReconnectingWebSocket | null
status: StreamDelayStatus | null
constructor({ endpoint, key }: StreamdelayClientOptions) {
super() super()
this.endpoint = endpoint this.endpoint = endpoint
this.key = key this.key = key
@@ -39,7 +51,7 @@ export default class StreamdelayClient extends EventEmitter {
} }
emitState() { emitState() {
const isConnected = this.ws.readyState === WebSocket.OPEN const isConnected = this.ws?.readyState === WebSocket.OPEN
if (isConnected && !this.status) { if (isConnected && !this.status) {
// Wait until we've received the first status message // Wait until we've received the first status message
return return
@@ -50,11 +62,13 @@ export default class StreamdelayClient extends EventEmitter {
}) })
} }
setCensored(isCensored) { setCensored(isCensored: boolean) {
assert(this.ws != null, 'Must be connected')
this.ws.send(JSON.stringify({ isCensored })) this.ws.send(JSON.stringify({ isCensored }))
} }
setStreamRunning(isStreamRunning) { setStreamRunning(isStreamRunning: boolean) {
assert(this.ws != null, 'Must be connected')
this.ws.send(JSON.stringify({ isStreamRunning })) this.ws.send(JSON.stringify({ isStreamRunning }))
} }
} }

View File

@@ -1,21 +1,25 @@
import TOML from '@iarna/toml'
import { Repeater } from '@repeaterjs/repeater'
import { watch } from 'chokidar'
import { EventEmitter, once } from 'events' import { EventEmitter, once } from 'events'
import { promises as fsPromises } from 'fs' import { promises as fsPromises } from 'fs'
import { promisify } from 'util' import { isArray } from 'lodash-es'
import { Repeater } from '@repeaterjs/repeater'
import TOML from '@iarna/toml'
import fetch from 'node-fetch' import fetch from 'node-fetch'
import chokidar from 'chokidar' import { promisify } from 'util'
import { StreamData, StreamList } from '../../../streamwall-shared/src/types'
const sleep = promisify(setTimeout) const sleep = promisify(setTimeout)
export async function* pollDataURL(url, intervalSecs) { type DataSource = AsyncGenerator<StreamData[]>
export async function* pollDataURL(url: string, intervalSecs: number) {
const refreshInterval = intervalSecs * 1000 const refreshInterval = intervalSecs * 1000
let lastData = [] let lastData = []
while (true) { while (true) {
let data = [] let data: StreamData[] = []
try { try {
const resp = await fetch(url) const resp = await fetch(url)
data = await resp.json() data = (await resp.json()) as StreamData[]
} catch (err) { } catch (err) {
console.warn('error loading stream data', err) console.warn('error loading stream data', err)
} }
@@ -32,24 +36,27 @@ export async function* pollDataURL(url, intervalSecs) {
} }
} }
export async function* watchDataFile(path) { export async function* watchDataFile(path: string): DataSource {
const watcher = chokidar.watch(path) const watcher = watch(path)
while (true) { while (true) {
let data let data
try { try {
const text = await fsPromises.readFile(path) const text = await fsPromises.readFile(path)
data = TOML.parse(text) data = TOML.parse(text.toString())
} catch (err) { } catch (err) {
console.warn('error reading data file', err) console.warn('error reading data file', err)
} }
if (data) { if (data && isArray(data.streams)) {
yield data.streams || [] // TODO: type validate with Zod
yield data.streams as unknown as StreamList
} else {
yield []
} }
await once(watcher, 'change') await once(watcher, 'change')
} }
} }
export async function* markDataSource(dataSource, name) { export async function* markDataSource(dataSource: DataSource, name: string) {
for await (const streamList of dataSource) { for await (const streamList of dataSource) {
for (const s of streamList) { for (const s of streamList) {
s._dataSource = name s._dataSource = name
@@ -58,16 +65,16 @@ export async function* markDataSource(dataSource, name) {
} }
} }
export async function* combineDataSources(dataSources) { export async function* combineDataSources(dataSources: DataSource[]) {
for await (const streamLists of Repeater.latest(dataSources)) { for await (const streamLists of Repeater.latest(dataSources)) {
const dataByURL = new Map() const dataByURL = new Map<string, StreamData>()
for (const list of streamLists) { for (const list of streamLists) {
for (const data of list) { for (const data of list) {
const existing = dataByURL.get(data.link) const existing = dataByURL.get(data.link)
dataByURL.set(data.link, { ...existing, ...data }) dataByURL.set(data.link, { ...existing, ...data })
} }
} }
const streams = [...dataByURL.values()] const streams: StreamList = [...dataByURL.values()]
// Retain the index to speed up local lookups // Retain the index to speed up local lookups
streams.byURL = dataByURL streams.byURL = dataByURL
yield streams yield streams
@@ -75,24 +82,23 @@ export async function* combineDataSources(dataSources) {
} }
export class LocalStreamData extends EventEmitter { export class LocalStreamData extends EventEmitter {
dataByURL: Map<string, Partial<StreamData>>
constructor() { constructor() {
super() super()
this.dataByURL = new Map() this.dataByURL = new Map()
} }
update(url, data) { update(url: string, data: Partial<StreamData>) {
if (!data.link) {
data.link = url
}
const existing = this.dataByURL.get(url) const existing = this.dataByURL.get(url)
this.dataByURL.set(data.link, { ...existing, ...data }) this.dataByURL.set(data.link ?? url, { ...existing, ...data, link: url })
if (url !== data.link) { if (data.link != null && url !== data.link) {
this.dataByURL.delete(url) this.dataByURL.delete(url)
} }
this._emitUpdate() this._emitUpdate()
} }
delete(url) { delete(url: string) {
this.dataByURL.delete(url) this.dataByURL.delete(url)
this._emitUpdate() this._emitUpdate()
} }
@@ -101,7 +107,7 @@ export class LocalStreamData extends EventEmitter {
this.emit('update', [...this.dataByURL.values()]) this.emit('update', [...this.dataByURL.values()])
} }
gen() { gen(): AsyncGenerator<StreamData[]> {
return new Repeater(async (push, stop) => { return new Repeater(async (push, stop) => {
await push([]) await push([])
this.on('update', push) this.on('update', push)
@@ -112,17 +118,21 @@ export class LocalStreamData extends EventEmitter {
} }
export class StreamIDGenerator { export class StreamIDGenerator {
idMap: Map<string, string>
idSet: Set<string>
constructor() { constructor() {
this.idMap = new Map() this.idMap = new Map()
this.idSet = new Set() this.idSet = new Set()
} }
process(streams) { process(streams: StreamData[]) {
const { idMap, idSet } = this const { idMap, idSet } = this
for (const stream of streams) { for (const stream of streams) {
const { link, source, label } = stream const { link, source, label } = stream
if (!idMap.has(link)) { let streamId = idMap.get(link)
if (streamId == null) {
let counter = 0 let counter = 0
let newId let newId
const idBase = source || label || link const idBase = source || label || link
@@ -141,11 +151,12 @@ export class StreamIDGenerator {
counter++ counter++
} while (idSet.has(newId)) } while (idSet.has(newId))
idMap.set(link, newId) streamId = newId
idSet.add(newId) idMap.set(link, streamId)
idSet.add(streamId)
} }
stream._id = idMap.get(link) stream._id = streamId
} }
return streams return streams
} }

View File

@@ -1,32 +1,60 @@
import fs from 'fs'
import path from 'path'
import yargs from 'yargs'
import TOML from '@iarna/toml' import TOML from '@iarna/toml'
import * as Y from 'yjs'
import * as Sentry from '@sentry/electron/main' import * as Sentry from '@sentry/electron/main'
import { app, shell, session, BrowserWindow } from 'electron' 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 { ensureValidURL } from '../util'
import ControlWindow from './ControlWindow'
import { import {
pollDataURL,
watchDataFile,
LocalStreamData, LocalStreamData,
StreamIDGenerator, StreamIDGenerator,
markDataSource,
combineDataSources, combineDataSources,
markDataSource,
pollDataURL,
watchDataFile,
} from './data' } from './data'
import * as persistence from './persistence'
import { Auth, StateWrapper } from './auth'
import StreamWindow from './StreamWindow'
import TwitchBot from './TwitchBot'
import StreamdelayClient from './StreamdelayClient' import StreamdelayClient from './StreamdelayClient'
import initWebServer from './server' import StreamWindow from './StreamWindow'
const SENTRY_DSN = const SENTRY_DSN =
'https://e630a21dcf854d1a9eb2a7a8584cbd0b@o459879.ingest.sentry.io/5459505' 'https://e630a21dcf854d1a9eb2a7a8584cbd0b@o459879.ingest.sentry.io/5459505'
function parseArgs() { export interface StreamwallConfig {
return yargs 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) => { .config('config', (configPath) => {
return TOML.parse(fs.readFileSync(configPath, 'utf-8')) return TOML.parse(fs.readFileSync(configPath, 'utf-8'))
}) })
@@ -73,7 +101,10 @@ function parseArgs() {
describe: 'Active (highlight) color of wall', describe: 'Active (highlight) color of wall',
default: '#fff', default: '#fff',
}) })
.group(['data.interval', 'data.json-url', 'data.toml-file'], 'Datasources') .group(
['data.interval', 'data.json-url', 'data.toml-file'],
'Datasources',
)
.option('data.interval', { .option('data.interval', {
describe: 'Interval (in seconds) for refreshing polled data sources', describe: 'Interval (in seconds) for refreshing polled data sources',
number: true, number: true,
@@ -82,6 +113,7 @@ function parseArgs() {
.option('data.json-url', { .option('data.json-url', {
describe: 'Fetch streams from the specified URL(s)', describe: 'Fetch streams from the specified URL(s)',
array: true, array: true,
string: true,
default: [], default: [],
}) })
.option('data.toml-file', { .option('data.toml-file', {
@@ -90,60 +122,7 @@ function parseArgs() {
array: true, array: true,
default: [], default: [],
}) })
.group( /*
[
'twitch.channel',
'twitch.username',
'twitch.password',
'twitch.color',
'twitch.announce.template',
'twitch.announce.interval',
'twitch.vote.template',
'twitch.vote.interval',
],
'Twitch Chat',
)
.option('twitch.channel', {
describe: 'Name of Twitch channel',
default: null,
})
.option('twitch.username', {
describe: 'Username of Twitch bot account',
default: null,
})
.option('twitch.password', {
describe: 'Password of Twitch bot account',
default: null,
})
.option('twitch.color', {
describe: 'Color of Twitch bot username',
default: '#ff0000',
})
.option('twitch.announce.template', {
describe: 'Message template for stream announcements',
default:
'SingsMic <%- stream.source %> <%- stream.city && stream.state ? `(${stream.city} ${stream.state})` : `` %> <%- stream.link %>',
})
.option('twitch.announce.interval', {
describe:
'Minimum time interval (in seconds) between re-announcing the same stream',
number: true,
default: 60,
})
.option('twitch.announce.delay', {
describe: 'Time to dwell on a stream before its details are announced',
number: true,
default: 30,
})
.option('twitch.vote.template', {
describe: 'Message template for vote result announcements',
default: 'Switching to #<%- selectedIdx %> (with <%- voteCount %> votes)',
})
.option('twitch.vote.interval', {
describe: 'Time interval (in seconds) between votes (0 to disable)',
number: true,
default: 0,
})
.group( .group(
[ [
'control.username', 'control.username',
@@ -169,6 +148,7 @@ function parseArgs() {
.option('control.address', { .option('control.address', {
describe: 'Enable control webserver and specify the URL', describe: 'Enable control webserver and specify the URL',
implies: ['control.username', 'control.password'], implies: ['control.username', 'control.password'],
string: true,
}) })
.option('control.hostname', { .option('control.hostname', {
describe: 'Override hostname the control server listens on', describe: 'Override hostname the control server listens on',
@@ -192,6 +172,7 @@ function parseArgs() {
.option('cert.email', { .option('cert.email', {
describe: 'Email for owner of SSL certificate', describe: 'Email for owner of SSL certificate',
}) })
*/
.group(['streamdelay.endpoint', 'streamdelay.key'], 'Streamdelay') .group(['streamdelay.endpoint', 'streamdelay.key'], 'Streamdelay')
.option('streamdelay.endpoint', { .option('streamdelay.endpoint', {
describe: 'URL of Streamdelay endpoint', describe: 'URL of Streamdelay endpoint',
@@ -207,10 +188,13 @@ function parseArgs() {
boolean: true, boolean: true,
default: true, default: true,
}) })
.help().argv .help()
// https://github.com/yargs/yargs/issues/2137
.parseSync() as unknown as StreamwallConfig
)
} }
async function main(argv) { async function main(argv: ReturnType<typeof parseArgs>) {
// Reject all permission requests from web content. // Reject all permission requests from web content.
session session
.fromPartition('persist:session') .fromPartition('persist:session')
@@ -218,67 +202,50 @@ async function main(argv) {
callback(false) callback(false)
}) })
console.debug('Loading persistence data...')
const persistData = await persistence.load()
console.debug('Creating StreamWindow...') console.debug('Creating StreamWindow...')
const idGen = new StreamIDGenerator() const idGen = new StreamIDGenerator()
const localStreamData = new LocalStreamData() const localStreamData = new LocalStreamData()
const overlayStreamData = new LocalStreamData() const overlayStreamData = new LocalStreamData()
const streamWindow = new StreamWindow({ const streamWindowConfig = {
gridCount: argv.grid.count, gridCount: argv.grid.count,
width: argv.window.width, width: argv.window.width,
height: argv.window.height, height: argv.window.height,
x: argv.window.x, x: argv.window.x,
y: argv.window.y, y: argv.window.y,
frameless: argv.window.frameless, frameless: argv.window.frameless,
activeColor: argv.window['active-color'],
backgroundColor: argv.window['background-color'], backgroundColor: argv.window['background-color'],
}) }
streamWindow.init() const streamWindow = new StreamWindow(streamWindowConfig)
const controlWindow = new ControlWindow()
console.debug('Creating Auth...') let browseWindow: BrowserWindow | null = null
const auth = new Auth({ let streamdelayClient: StreamdelayClient | null = null
adminUsername: argv.control.username,
adminPassword: argv.control.password,
persistData: persistData.auth,
logEnabled: true,
})
let browseWindow = null
let twitchBot = null
let streamdelayClient = null
console.debug('Creating initial state...') console.debug('Creating initial state...')
let clientState = new StateWrapper({ let clientState: StreamwallState = {
config: { config: streamWindowConfig,
width: argv.window.width,
height: argv.window.height,
gridCount: argv.grid.count,
activeColor: argv.window['active-color'],
},
auth: auth.getState(),
streams: [], streams: [],
views: [], views: [],
streamdelay: null, streamdelay: null,
}) }
const stateDoc = new Y.Doc() const stateDoc = new Y.Doc()
const viewsState = stateDoc.getMap('views') const viewsState = stateDoc.getMap<Y.Map<string | undefined>>('views')
stateDoc.transact(() => { stateDoc.transact(() => {
for (let i = 0; i < argv.grid.count ** 2; i++) { for (let i = 0; i < argv.grid.count ** 2; i++) {
const data = new Y.Map() const data = new Y.Map<string | undefined>()
data.set('streamId', '') data.set('streamId', undefined)
viewsState.set(i, data) viewsState.set(String(i), data)
} }
}) })
viewsState.observeDeep(() => { viewsState.observeDeep(() => {
try { try {
const viewContentMap = new Map() const viewContentMap = new Map()
for (const [key, viewData] of viewsState) { for (const [key, viewData] of viewsState) {
const stream = clientState.info.streams.find( const streamId = viewData.get('streamId')
(s) => s._id === viewData.get('streamId'), const stream = clientState.streams.find((s) => s._id === streamId)
)
if (!stream) { if (!stream) {
continue continue
} }
@@ -287,19 +254,23 @@ async function main(argv) {
kind: stream.kind || 'video', kind: stream.kind || 'video',
}) })
} }
streamWindow.setViews(viewContentMap, clientState.info.streams) streamWindow.setViews(viewContentMap, clientState.streams)
} catch (err) { } catch (err) {
console.error('Error updating views', err) console.error('Error updating views', err)
} }
}) })
const onMessage = async (msg, respond) => { const onCommand = async (msg: ControlCommand) => {
console.debug('Received message:', msg) console.debug('Received message:', msg)
if (msg.type === 'set-listening-view') { if (msg.type === 'set-listening-view') {
console.debug('Setting listening view:', msg.viewIdx) console.debug('Setting listening view:', msg.viewIdx)
streamWindow.setListeningView(msg.viewIdx) streamWindow.setListeningView(msg.viewIdx)
} else if (msg.type === 'set-view-background-listening') { } else if (msg.type === 'set-view-background-listening') {
console.debug('Setting view background listening:', msg.viewIdx, msg.listening) console.debug(
'Setting view background listening:',
msg.viewIdx,
msg.listening,
)
streamWindow.setViewBackgroundListening(msg.viewIdx, msg.listening) streamWindow.setViewBackgroundListening(msg.viewIdx, msg.listening)
} else if (msg.type === 'set-view-blurred') { } else if (msg.type === 'set-view-blurred') {
console.debug('Setting view blurred:', msg.viewIdx, msg.blurred) console.debug('Setting view blurred:', msg.viewIdx, msg.blurred)
@@ -357,7 +328,8 @@ async function main(argv) {
} else if (msg.type === 'set-stream-running' && streamdelayClient) { } else if (msg.type === 'set-stream-running' && streamdelayClient) {
console.debug('Setting stream running:', msg.isStreamRunning) console.debug('Setting stream running:', msg.isStreamRunning)
streamdelayClient.setStreamRunning(msg.isStreamRunning) streamdelayClient.setStreamRunning(msg.isStreamRunning)
} else if (msg.type === 'create-invite') { // TODO: Move to control server
/*} else if (msg.type === 'create-invite') {
console.debug('Creating invite for role:', msg.role) console.debug('Creating invite for role:', msg.role)
const { secret } = await auth.createToken({ const { secret } = await auth.createToken({
kind: 'invite', kind: 'invite',
@@ -368,17 +340,61 @@ async function main(argv) {
} else if (msg.type === 'delete-token') { } else if (msg.type === 'delete-token') {
console.debug('Deleting token:', msg.tokenId) console.debug('Deleting token:', msg.tokenId)
auth.deleteToken(msg.tokenId) auth.deleteToken(msg.tokenId)
*/
} }
} }
function updateState(newState) { function updateState(newState: Partial<StreamwallState>) {
clientState.update(newState) clientState = { ...clientState, ...newState }
streamWindow.onState(clientState.info) streamWindow.onState(clientState)
if (twitchBot) { controlWindow.onState(clientState)
twitchBot.onState(clientState.info)
}
} }
// 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) { if (argv.control.address) {
console.debug('Initializing web server...') console.debug('Initializing web server...')
const webDistPath = path.join(app.getAppPath(), 'web') const webDistPath = path.join(app.getAppPath(), 'web')
@@ -400,40 +416,7 @@ async function main(argv) {
shell.openExternal(argv.control.address) shell.openExternal(argv.control.address)
} }
} }
*/
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.twitch.token) {
console.debug('Setting up Twitch bot...')
twitchBot = new TwitchBot(argv.twitch)
twitchBot.on('setListeningView', (idx) => {
streamWindow.setListeningView(idx)
})
twitchBot.connect()
}
streamWindow.on('state', (viewStates) => {
updateState({ views: viewStates })
})
streamWindow.on('close', () => {
process.exit(0)
})
auth.on('state', (authState) => {
updateState({ auth: authState })
persistence.save({ auth: auth.getPersistData() })
})
const dataSources = [ const dataSources = [
...argv.data['json-url'].map((url) => { ...argv.data['json-url'].map((url) => {
@@ -467,22 +450,28 @@ function init() {
Sentry.init({ dsn: SENTRY_DSN }) Sentry.init({ dsn: SENTRY_DSN })
} }
updateElectronApp()
console.debug('Setting up Electron...') console.debug('Setting up Electron...')
app.commandLine.appendSwitch('high-dpi-support', 1) app.commandLine.appendSwitch('high-dpi-support', '1')
app.commandLine.appendSwitch('force-device-scale-factor', 1) app.commandLine.appendSwitch('force-device-scale-factor', '1')
console.debug('Enabling Electron sandbox...') console.debug('Enabling Electron sandbox...')
app.enableSandbox() app.enableSandbox()
app app
.whenReady() .whenReady()
.then(() => main(argv)) .then(() => main(argv))
.catch((err) => { .catch((err) => {
console.trace(err.toString()) console.error(err)
process.exit(1) process.exit(1)
}) })
} }
if (require.main === module) { // Handle creating/removing shortcuts on Windows when installing/uninstalling.
console.debug('Starting Streamwall...') if (started) {
init() 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

@@ -1,5 +1,6 @@
import { ipcRenderer, webFrame } from 'electron' import { ipcRenderer, webFrame } from 'electron'
import throttle from 'lodash/throttle' import throttle from 'lodash/throttle'
import { ContentDisplayOptions } from 'streamwall-shared'
const SCAN_THROTTLE = 500 const SCAN_THROTTLE = 500
@@ -64,14 +65,19 @@ const NO_SCROLL_STYLE = `
} }
` `
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) const sleep = (ms: number) =>
new Promise<void>((resolve) => setTimeout(() => resolve(), ms))
const pageReady = new Promise((resolve) => const pageReady = new Promise((resolve) =>
document.addEventListener('DOMContentLoaded', resolve, { once: true }), document.addEventListener('DOMContentLoaded', resolve, { once: true }),
) )
class RotationController { class RotationController {
constructor(video) { video: HTMLVideoElement
siteRotation: number
customRotation: number
constructor(video: HTMLVideoElement) {
this.video = video this.video = video
this.siteRotation = 0 this.siteRotation = 0
this.customRotation = 0 this.customRotation = 0
@@ -104,7 +110,7 @@ async function lockdownMediaTags() {
if (el.__sw) { if (el.__sw) {
continue continue
} }
// Prevent sites from re-muting the video (Periscope, I'm looking at you!) // Prevent sites from re-muting the video
Object.defineProperty(el, 'muted', { writable: true, value: false }) Object.defineProperty(el, 'muted', { writable: true, value: false })
// Prevent Facebook from pausing the video after page load. // Prevent Facebook from pausing the video after page load.
Object.defineProperty(el, 'pause', { writable: false, value: () => {} }) Object.defineProperty(el, 'pause', { writable: false, value: () => {} })
@@ -117,9 +123,10 @@ async function lockdownMediaTags() {
observer.observe(document.body, { subtree: true, childList: true }) observer.observe(document.body, { subtree: true, childList: true })
} }
function waitForQuery(query) { async function waitForQuery(query: string): Promise<Element> {
console.log(`waiting for '${query}'...`) console.log(`waiting for '${query}'...`)
return new Promise(async (resolve) => { await pageReady
return new Promise((resolve) => {
const scan = throttle(() => { const scan = throttle(() => {
const el = document.querySelector(query) const el = document.querySelector(query)
if (el) { if (el) {
@@ -129,79 +136,36 @@ function waitForQuery(query) {
} }
}, SCAN_THROTTLE) }, SCAN_THROTTLE)
await pageReady
const observer = new MutationObserver(scan) const observer = new MutationObserver(scan)
observer.observe(document.body, { subtree: true, childList: true }) observer.observe(document.body, { subtree: true, childList: true })
scan() scan()
}) })
} }
async function waitForVideo(kind) { async function waitForVideo(kind: 'video' | 'audio'): Promise<{
video?: HTMLMediaElement
iframe?: HTMLIFrameElement
}> {
lockdownMediaTags() lockdownMediaTags()
let video = await Promise.race([waitForQuery(kind), sleep(10 * 1000)]) let video: Element | null | void = await Promise.race([
if (video) { waitForQuery(kind),
sleep(10 * 1000),
])
if (video instanceof HTMLMediaElement) {
return { video } return { video }
} }
let iframe let iframe
for (iframe of document.querySelectorAll('iframe')) { for (iframe of document.querySelectorAll('iframe')) {
video = iframe.contentDocument?.querySelector?.(kind) video = iframe.contentDocument?.querySelector?.(kind)
if (video) { if (video instanceof HTMLVideoElement) {
return { video, iframe } return { video, iframe }
} }
} }
return {} return {}
} }
const periscopeHacks = {
isMatch() {
return (
location.host === 'www.pscp.tv' || location.host === 'www.periscope.tv'
)
},
async onLoad() {
const playButton = await Promise.race([
waitForQuery('.PlayButton'),
sleep(1000),
])
if (playButton) {
playButton.click()
}
},
afterPlay(rotationController) {
const baseVideoEl = document.querySelector('div.BaseVideo')
if (!baseVideoEl) {
return
}
function positionPeriscopeVideo() {
// Periscope videos can be rotated using transform matrix. They need to be rotated correctly.
const tr = baseVideoEl.style.transform
let rotation
if (tr.endsWith('matrix(0, 1, -1, 0, 0, 0)')) {
rotation = 90
} else if (tr.endsWith('matrix(-1, 0, 0, -1, 0)')) {
rotation = 180
} else if (tr.endsWith('matrix(0, -1, 1, 0, 0, 0)')) {
rotation = 270
}
rotationController.setSite(rotation)
}
positionPeriscopeVideo()
const obs = new MutationObserver((ml) => {
for (const m of ml) {
if (m.attributeName === 'style') {
positionPeriscopeVideo()
return
}
}
})
obs.observe(baseVideoEl, { attributes: true })
},
}
const igHacks = { const igHacks = {
isMatch() { isMatch() {
return location.host === 'www.instagram.com' return location.host === 'www.instagram.com'
@@ -213,6 +177,7 @@ const igHacks = {
sleep(1000), sleep(1000),
]) ])
if ( if (
playButton instanceof HTMLButtonElement &&
playButton.tagName === 'BUTTON' && playButton.tagName === 'BUTTON' &&
playButton.textContent === 'Tap to play' playButton.textContent === 'Tap to play'
) { ) {
@@ -221,10 +186,7 @@ const igHacks = {
}, },
} }
async function findVideo(kind) { async function findVideo(kind: 'video' | 'audio') {
if (periscopeHacks.isMatch()) {
await periscopeHacks.onLoad()
}
if (igHacks.isMatch()) { if (igHacks.isMatch()) {
await igHacks.onLoad() await igHacks.onLoad()
} }
@@ -233,7 +195,7 @@ async function findVideo(kind) {
if (!video) { if (!video) {
throw new Error('could not find video') throw new Error('could not find video')
} }
if (iframe) { if (iframe && iframe.contentDocument) {
// TODO: verify iframe still works // TODO: verify iframe still works
const style = iframe.contentDocument.createElement('style') const style = iframe.contentDocument.createElement('style')
style.innerHTML = VIDEO_OVERRIDE_STYLE style.innerHTML = VIDEO_OVERRIDE_STYLE
@@ -251,7 +213,7 @@ async function findVideo(kind) {
video.play() video.play()
if (!video.videoWidth) { if (video instanceof HTMLVideoElement && !video.videoWidth) {
console.log(`video isn't playing yet. waiting for it to start...`) console.log(`video isn't playing yet. waiting for it to start...`)
const videoReady = new Promise((resolve) => const videoReady = new Promise((resolve) =>
video.addEventListener('playing', resolve, { once: true }), video.addEventListener('playing', resolve, { once: true }),
@@ -280,15 +242,12 @@ async function main() {
pageReady, pageReady,
]) ])
let rotationController let rotationController: RotationController | undefined
if (content.kind === 'video' || content.kind === 'audio') { if (content.kind === 'video' || content.kind === 'audio') {
webFrame.insertCSS(VIDEO_OVERRIDE_STYLE, { cssOrigin: 'user' }) webFrame.insertCSS(VIDEO_OVERRIDE_STYLE, { cssOrigin: 'user' })
const { info, video } = await findVideo(content.kind) const { info, video } = await findVideo(content.kind)
if (content.kind === 'video') { if (content.kind === 'video' && video instanceof HTMLVideoElement) {
rotationController = new RotationController(video) rotationController = new RotationController(video)
if (periscopeHacks.isMatch()) {
periscopeHacks.afterPlay(rotationController)
}
} }
ipcRenderer.send('view-info', { info }) ipcRenderer.send('view-info', { info })
} else if (content.kind === 'web') { } else if (content.kind === 'web') {
@@ -297,7 +256,7 @@ async function main() {
ipcRenderer.send('view-loaded') ipcRenderer.send('view-loaded')
function updateOptions(options) { function updateOptions(options: ContentDisplayOptions) {
if (rotationController) { if (rotationController) {
rotationController.setCustom(options.rotation) rotationController.setCustom(options.rotation)
} }
@@ -306,6 +265,6 @@ async function main() {
updateOptions(initialOptions) updateOptions(initialOptions)
} }
main().catch((err) => { main().catch((error) => {
ipcRenderer.send('view-error', { err }) ipcRenderer.send('view-error', { error })
}) })

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html> <html>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@@ -9,6 +9,6 @@
/> />
</head> </head>
<body> <body>
<script src="background.js" type="module"></script> <script src="background.tsx" type="module"></script>
</body> </body>
</html> </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

@@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html> <html>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@@ -9,6 +9,6 @@
/> />
</head> </head>
<body> <body>
<script src="overlay.js" type="module"></script> <script src="overlay.tsx" type="module"></script>
</body> </body>
</html> </html>

View File

@@ -1,40 +1,64 @@
import { h, Fragment, render } from 'preact'
import { useEffect, useState } from 'preact/hooks'
import { State } from 'xstate'
import styled from 'styled-components'
import { useHotkeys } from 'react-hotkeys-hook'
import { TailSpin } from 'svg-loaders-react'
import Color from 'color' 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 '../index.css' import '@fontsource/noto-sans'
import './index.css'
import InstagramIcon from '../static/instagram.svg' declare global {
import FacebookIcon from '../static/facebook.svg' interface Window {
import PeriscopeIcon from '../static/periscope.svg' streamwallLayer: StreamwallLayerGlobal
import TwitchIcon from '../static/twitch.svg' }
import YouTubeIcon from '../static/youtube.svg' }
import TikTokIcon from '../static/tiktok.svg'
import SoundIcon from '../static/volume-up-solid.svg'
function Overlay({ config, views, streams }) { function Overlay({
config,
views,
streams,
}: Pick<StreamwallState, 'config' | 'views' | 'streams'>) {
const { width, height, activeColor } = config const { width, height, activeColor } = config
const activeViews = views const activeViews = views.filter(
.map(({ state, context }) => State.from(state, context)) ({ state }) =>
.filter((s) => s.matches('displaying') && !s.matches('displaying.error')) matchesState('displaying', state) &&
!matchesState('displaying.error', state),
)
const overlays = streams.filter((s) => s.kind === 'overlay') const overlays = streams.filter((s) => s.kind === 'overlay')
return ( return (
<div> <div>
{activeViews.map((viewState) => { {activeViews.map(({ state, context }) => {
const { content, pos } = viewState.context const { content, pos } = context
if (!content) {
return
}
const data = streams.find((d) => content.url === d.link) const data = streams.find((d) => content.url === d.link)
const isListening = viewState.matches( const isListening = matchesState(
'displaying.running.audio.listening', 'displaying.running.audio.listening',
state,
) )
const isBackgroundListening = viewState.matches( const isBackgroundListening = matchesState(
'displaying.running.audio.background', 'displaying.running.audio.background',
state,
) )
const isBlurred = viewState.matches('displaying.running.video.blurred') const isBlurred = matchesState(
const isLoading = viewState.matches('displaying.loading') 'displaying.running.video.blurred',
state,
)
const isLoading = matchesState('displaying.loading', state)
const hasTitle = data && (data.label || data.source) const hasTitle = data && (data.label || data.source)
const position = data?.labelPosition ?? 'top-left' const position = data?.labelPosition ?? 'top-left'
return ( return (
@@ -53,16 +77,8 @@ function Overlay({ config, views, streams }) {
isListening={isListening} isListening={isListening}
> >
<StreamIcon url={content.url} /> <StreamIcon url={content.url} />
<span> <span>{data.label}</span>
{data.hasOwnProperty('label') ? ( {(isListening || isBackgroundListening) && <FaVolumeUp />}
data.label
) : (
<>
{data.source} &ndash; {data.city} {data.state}
</>
)}
</span>
{(isListening || isBackgroundListening) && <SoundIcon />}
</StreamTitle> </StreamTitle>
)} )}
{isLoading && <LoadingSpinner />} {isLoading && <LoadingSpinner />}
@@ -82,33 +98,27 @@ function Overlay({ config, views, streams }) {
} }
function App() { function App() {
const [state, setState] = useState({ const [state, setState] = useState<StreamwallState | undefined>()
config: {},
views: [],
streams: [],
customStreams: [],
})
useEffect(() => { useEffect(() => {
streamwall.onState(setState) const unsubscribe = window.streamwallLayer.onState(setState)
window.streamwallLayer.load()
return unsubscribe
}, []) }, [])
useHotkeys('ctrl+shift+i', () => { useHotkeys('ctrl+shift+i', () => {
streamwall.openDevTools() window.streamwallLayer.openDevTools()
}) })
const { config, views, streams, customStreams } = state if (!state) {
return ( return
<Overlay }
config={config}
views={views} const { config, views, streams } = state
streams={streams} return <Overlay config={config} views={views} streams={streams} />
customStreams={customStreams}
/>
)
} }
function StreamIcon({ url, ...props }) { function StreamIcon({ url }: { url: string }) {
let parsedURL let parsedURL
try { try {
parsedURL = new URL(url) parsedURL = new URL(url)
@@ -119,26 +129,20 @@ function StreamIcon({ url, ...props }) {
let { host } = parsedURL let { host } = parsedURL
host = host.replace(/^www\./, '') host = host.replace(/^www\./, '')
if (host === 'youtube.com' || host === 'youtu.be') { if (host === 'youtube.com' || host === 'youtu.be') {
return <YouTubeIcon {...props} /> return <FaYoutube />
} else if (host === 'facebook.com' || host === 'm.facebook.com') { } else if (host === 'facebook.com' || host === 'm.facebook.com') {
return <FacebookIcon {...props} /> return <FaFacebook />
} else if (host === 'twitch.tv') { } else if (host === 'twitch.tv') {
return <TwitchIcon {...props} /> return <FaTwitch />
} else if (
host === 'periscope.tv' ||
host === 'pscp.tv' ||
host === 'twitter.com'
) {
return <PeriscopeIcon {...props} />
} else if (host === 'instagram.com') { } else if (host === 'instagram.com') {
return <InstagramIcon {...props} /> return <FaInstagram />
} else if (host === 'tiktok.com') { } else if (host === 'tiktok.com') {
return <TikTokIcon {...props} /> return <FaTiktok />
} }
return null return null
} }
const SpaceBorder = styled.div.attrs((props) => ({ const SpaceBorder = styled.div.attrs(() => ({
borderWidth: 2, borderWidth: 2,
}))` }))`
display: flex; display: flex;
@@ -190,8 +194,16 @@ const StreamTitle = styled.div`
color: white; color: white;
text-shadow: 0 0 4px black; text-shadow: 0 0 4px black;
letter-spacing: -0.025em; letter-spacing: -0.025em;
background: ${({ isListening, activeColor }) => background: ${({
Color(isListening ? activeColor : 'black').alpha(0.5)}; isListening,
activeColor,
}: {
isListening: boolean
activeColor: string
}) =>
Color(isListening ? activeColor : 'black')
.alpha(0.5)
.toString()};
border-radius: 4px; border-radius: 4px;
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
overflow: hidden; overflow: hidden;

View File

@@ -1,4 +1,4 @@
<!DOCTYPE html> <!doctype html>
<html> <html>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
@@ -9,6 +9,6 @@
/> />
</head> </head>
<body> <body>
<script src="playHLS.js" type="module"></script> <script src="playHLS.ts" type="module"></script>
</body> </body>
</html> </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

@@ -1,4 +1,4 @@
export function ensureValidURL(urlStr) { export function ensureValidURL(urlStr: string) {
const url = new URL(urlStr) const url = new URL(urlStr)
if (url.protocol !== 'http:' && url.protocol !== 'https:') { if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error(`rejecting attempt to load non-http URL '${urlStr}'`) 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[]),
],
})

View File

@@ -3,4 +3,5 @@ module.exports = {
tabWidth: 2, tabWidth: 2,
semi: false, semi: false,
singleQuote: true, singleQuote: true,
plugins: ['prettier-plugin-organize-imports'],
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -1,45 +0,0 @@
import { h, render } from 'preact'
import { useEffect, useState } from 'preact/hooks'
import styled from 'styled-components'
import '../index.css'
function Background({ streams }) {
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 [state, setState] = useState({
streams: [],
})
useEffect(() => {
streamwall.onState(setState)
}, [])
const { streams } = state
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

@@ -1,6 +0,0 @@
import { ipcRenderer, contextBridge } from 'electron'
contextBridge.exposeInMainWorld('streamwall', {
openDevTools: () => ipcRenderer.send('devtools-overlay'),
onState: (handler) => ipcRenderer.on('state', (ev, state) => handler(state)),
})

View File

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

View File

@@ -1,150 +0,0 @@
import { boxesFromViewContentMap, idxInBox, idxToCoords } from './geometry'
function example([text]) {
return text
.replace(/\s/g, '')
.split('')
.map((c) => (c === '.' ? undefined : { url: c }))
}
const box1 = example`
ab
ab
`
const box2 = example`
aa
bb
`
const box3 = example`
aac
aaa
dae
`
const box4 = example`
...
.aa
.aa
`
const box5 = example`
..a
..a
.aa
`
describe.each([
[
2,
2,
box1,
[
{ content: { url: 'a' }, x: 0, y: 0, w: 1, h: 2, spaces: [0, 2] },
{ content: { url: 'b' }, x: 1, y: 0, w: 1, h: 2, spaces: [1, 3] },
],
],
[
2,
2,
box2,
[
{ content: { url: 'a' }, x: 0, y: 0, w: 2, h: 1, spaces: [0, 1] },
{ content: { url: 'b' }, x: 0, y: 1, w: 2, h: 1, spaces: [2, 3] },
],
],
[
3,
3,
box3,
[
{ content: { url: 'a' }, x: 0, y: 0, w: 2, h: 2, spaces: [0, 1, 3, 4] },
{ content: { url: 'c' }, x: 2, y: 0, w: 1, h: 1, spaces: [2] },
{ content: { url: 'a' }, x: 2, y: 1, w: 1, h: 1, spaces: [5] },
{ content: { url: 'd' }, x: 0, y: 2, w: 1, h: 1, spaces: [6] },
{ content: { url: 'a' }, x: 1, y: 2, w: 1, h: 1, spaces: [7] },
{ content: { url: 'e' }, x: 2, y: 2, w: 1, h: 1, spaces: [8] },
],
],
[
3,
3,
box4,
[{ content: { url: 'a' }, x: 1, y: 1, w: 2, h: 2, spaces: [4, 5, 7, 8] }],
],
[
3,
3,
box5,
[
{ content: { url: 'a' }, x: 2, y: 0, w: 1, h: 3, spaces: [2, 5, 8] },
{ content: { url: 'a' }, x: 1, y: 2, w: 1, h: 1, spaces: [7] },
],
],
])('boxesFromViewContentMap(%i, %i, %j)', (width, height, data, expected) => {
test(`returns expected ${expected.length} boxes`, () => {
const stateURLMap = new Map(data.map((v, idx) => [idx, v]))
const result = boxesFromViewContentMap(width, height, stateURLMap)
expect(result).toStrictEqual(expected)
})
})
describe.each([
[
'a middle index',
5,
12,
{ x: 2, y: 2 },
],
[
'the top-left corner',
5,
0,
{ x: 0, y: 0 },
],
[
'the top-right corner',
5,
4,
{ x: 4, y: 0 },
],
[
'the bottom-left corner',
5,
20,
{ x: 0, y: 4 },
],
[
'the bottom-right corner',
5,
24,
{ x: 4, y: 4 },
],
])('idxToCoords', (humanized_location, gridCount, idx, coords) => {
test(`should support ${humanized_location}`, () => {
const result = idxToCoords(gridCount, idx)
expect(result).toEqual(coords)
})
})
describe('idxInBox', () => {
it('should return true if index is within the box', () => {
const gridCount = 5
const start = 0
const end = 24
const idx = 12
const result = idxInBox(gridCount, start, end, idx)
expect(result).toBe(true)
})
it('should return false if index is outside the box', () => {
const gridCount = 5
const start = 0
const end = 24
const idx = 25
const result = idxInBox(gridCount, start, end, idx)
expect(result).toBe(false)
})
})

View File

@@ -1,15 +0,0 @@
@font-face {
font-family: 'Noto Sans';
font-weight: normal;
src: url(./static/NotoSans-Regular.ttf) format('truetype');
}
@font-face {
font-family: 'Noto Sans';
font-weight: 600;
src: url(./static/NotoSans-SemiBold.ttf) format('truetype');
}
body {
font-family: 'Noto Sans';
}

View File

@@ -1,336 +0,0 @@
import path from 'path'
import isEqual from 'lodash/isEqual'
import intersection from 'lodash/intersection'
import EventEmitter from 'events'
import { app, BrowserView, BrowserWindow, ipcMain } from 'electron'
import { interpret } from 'xstate'
import viewStateMachine from './viewStateMachine'
import { boxesFromViewContentMap } from '../geometry'
function getDisplayOptions(stream) {
if (!stream) {
return {}
}
const { rotation } = stream
return { rotation }
}
export default class StreamWindow extends EventEmitter {
constructor(config) {
super()
const { width, height, gridCount } = config
config.spaceWidth = Math.floor(width / gridCount)
config.spaceHeight = Math.floor(height / gridCount)
this.config = config
this.win = null
this.offscreenWin = null
this.backgroundView = null
this.overlayView = null
this.views = new Map()
this.viewActions = null
}
init() {
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 BrowserView({
webPreferences: {
contextIsolation: true,
preload: path.join(app.getAppPath(), 'layerPreload.js'),
},
})
win.addBrowserView(backgroundView)
backgroundView.setBounds({
x: 0,
y: 0,
width,
height,
useContentSize: true,
})
backgroundView.webContents.loadFile('background.html')
this.backgroundView = backgroundView
const overlayView = new BrowserView({
webPreferences: {
contextIsolation: true,
preload: path.join(app.getAppPath(), 'layerPreload.js'),
},
})
win.addBrowserView(overlayView)
overlayView.setBounds({
x: 0,
y: 0,
width,
height,
})
overlayView.webContents.loadFile('overlay.html')
this.overlayView = overlayView
this.viewActions = {
offscreenView: (context, event) => {
const { view } = 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.
win.removeBrowserView(view)
offscreenWin.addBrowserView(view)
view.setBounds({ x: 0, y: 0, width, height })
},
positionView: (context, event) => {
const { pos, view } = context
offscreenWin.removeBrowserView(view)
win.addBrowserView(view)
view.setBounds(pos)
// It's necessary to remove and re-add the overlay view to ensure it's on top.
win.removeBrowserView(overlayView)
win.addBrowserView(overlayView)
},
}
ipcMain.handle('view-init', async (ev) => {
const view = this.views.get(ev.sender.id)
if (view) {
view.send({ type: 'VIEW_INIT' })
return {
content: view.state.context.content,
options: view.state.context.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, { err }) => {
this.views.get(ev.sender.id)?.send?.({ type: 'VIEW_ERROR', err })
})
ipcMain.on('devtools-overlay', () => {
overlayView.webContents.openDevTools()
})
}
createView() {
const { win, overlayView, viewActions } = this
const { backgroundColor } = this.config
const view = new BrowserView({
webPreferences: {
preload: path.join(app.getAppPath(), 'mediaPreload.js'),
nodeIntegration: false,
enableRemoteModule: false,
contextIsolation: true,
worldSafeExecuteJavaScript: true,
partition: 'persist:session',
// Force BrowserView visibility to start visible.
// This is important because some pages block on visibility / RAF to display the video.
// See: https://github.com/electron/electron/pull/21372
show: true,
},
})
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 machine = viewStateMachine
.withContext({
...viewStateMachine.context,
id: viewId,
view,
parentWin: win,
overlayView,
})
.withConfig({ actions: viewActions })
const service = interpret(machine).start()
service.onTransition((state) => {
if (!state.changed) {
return
}
this.emitState(state)
})
return service
}
emitState() {
const states = Array.from(this.views.values(), ({ state }) => ({
state: state.value,
context: {
id: state.context.id,
content: state.context.content,
info: state.context.info,
pos: state.context.pos,
},
}))
this.emit('state', states)
}
setViews(viewContentMap, streams) {
const { gridCount, spaceWidth, spaceHeight } = this.config
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 = [
// First try to find a loaded view of the same URL in the same space...
(v, content, spaces) =>
isEqual(v.state.context.content, content) &&
v.state.matches('displaying.running') &&
intersection(v.state.context.pos.spaces, spaces).length > 0,
// Then try to find a loaded view of the same URL...
(v, content) =>
isEqual(v.state.context.content, content) &&
v.state.matches('displaying.running'),
// Then try view with the same URL that is still loading...
(v, content) => isEqual(v.state.context.content, content),
]
for (const matcher of matchers) {
for (const box of remainingBoxes) {
const { content, spaces } = box
let foundView
for (const view of unusedViews) {
if (matcher(view, 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
const pos = {
x: spaceWidth * x,
y: spaceHeight * y,
width: spaceWidth * w,
height: spaceHeight * h,
spaces,
}
const stream = streams.byURL.get(content.url)
view.send({ type: 'OPTIONS', options: getDisplayOptions(stream) })
view.send({ type: 'DISPLAY', pos, content })
newViews.set(view.state.context.id, view)
}
for (const view of unusedViews) {
const browserView = view.state.context.view
win.removeBrowserView(browserView)
browserView.webContents.destroy()
}
this.views = newViews
this.emitState()
}
setListeningView(viewIdx) {
const { views } = this
for (const view of views.values()) {
if (!view.state.matches('displaying')) {
continue
}
const { context } = view.state
const isSelectedView = context.pos.spaces.includes(viewIdx)
view.send(isSelectedView ? 'UNMUTE' : 'MUTE')
}
}
findViewByIdx(viewIdx) {
for (const view of this.views.values()) {
if (view.state.context.pos?.spaces?.includes?.(viewIdx)) {
return view
}
}
}
sendViewEvent(viewIdx, event) {
const view = this.findViewByIdx(viewIdx)
if (view) {
view.send(event)
}
}
setViewBackgroundListening(viewIdx, listening) {
this.sendViewEvent(viewIdx, listening ? 'BACKGROUND' : 'UNBACKGROUND')
}
setViewBlurred(viewIdx, blurred) {
this.sendViewEvent(viewIdx, blurred ? 'BLUR' : 'UNBLUR')
}
reloadView(viewIdx) {
this.sendViewEvent(viewIdx, 'RELOAD')
}
openDevTools(viewIdx, inWebContents) {
this.sendViewEvent(viewIdx, { type: 'DEVTOOLS', inWebContents })
}
onState(state) {
this.send('state', state)
for (const view of this.views.values()) {
const { url } = view.state.context.content
const stream = state.streams.byURL.get(url)
if (stream) {
view.send({
type: 'OPTIONS',
options: getDisplayOptions(stream),
})
}
}
}
send(...args) {
this.overlayView.webContents.send(...args)
this.backgroundView.webContents.send(...args)
}
}

View File

@@ -1,165 +0,0 @@
import EventEmitter from 'events'
import Color from 'color'
import ejs from 'ejs'
import { State } from 'xstate'
import { ChatClient, SlowModeRateLimiter, LoginError } from 'dank-twitch-irc'
const VOTE_RE = /^!(\d+)$/
export default class TwitchBot extends EventEmitter {
constructor(config) {
super()
const { username, token, vote } = config
this.config = config
this.announceTemplate = ejs.compile(config.announce.template)
const client = new ChatClient({
username,
password: `oauth:${token}`,
rateLimits: 'default',
})
client.use(new SlowModeRateLimiter(client, 0))
this.client = client
this.streams = null
this.listeningURL = null
this.dwellTimeout = null
this.announceTimeouts = new Map()
if (vote.interval) {
this.voteTemplate = ejs.compile(config.vote.template)
this.votes = new Map()
setInterval(this.tallyVotes.bind(this), vote.interval * 1000)
}
client.on('ready', () => {
this.onReady()
})
client.on('error', (err) => {
console.error('Twitch connection error:', err)
if (err instanceof LoginError) {
client.close()
}
})
client.on('close', (err) => {
console.log('Twitch bot disconnected.')
if (err != null) {
console.error('Twitch bot disconnected due to error:', err)
}
})
client.on('PRIVMSG', (msg) => {
this.onMsg(msg)
})
}
connect() {
const { client } = this
client.connect()
}
async onReady() {
const { client } = this
const { channel, color } = this.config
await client.setColor(Color(color).object())
await client.join(channel)
this.emit('connected')
}
onState({ views, streams }) {
this.streams = streams
const listeningView = views.find(({ state, context }) =>
State.from(state, context).matches('displaying.running.audio.listening'),
)
if (!listeningView) {
return
}
const listeningURL = listeningView.context.content.url
if (listeningURL === this.listeningURL) {
return
}
this.listeningURL = listeningURL
this.onListeningURLChange(listeningURL)
}
onListeningURLChange(listeningURL) {
const { announce } = this.config
clearTimeout(this.dwellTimeout)
this.dwellTimeout = setTimeout(() => {
if (!this.announceTimeouts.has(listeningURL)) {
this.announce()
}
}, announce.delay * 1000)
}
async announce() {
const { client, listeningURL, streams } = this
const { channel, announce } = this.config
if (!client.ready) {
return
}
const stream = streams.find((s) => s.link === listeningURL)
if (!stream) {
return
}
const msg = this.announceTemplate({ stream })
await client.say(channel, msg)
const timeout = setTimeout(() => {
this.announceTimeouts.delete(listeningURL)
if (this.listeningURL === listeningURL) {
this.announce()
}
}, announce.interval * 1000)
this.announceTimeouts.set(listeningURL, timeout)
}
async tallyVotes() {
const { client } = this
const { channel } = this.config
if (this.votes.size === 0) {
return
}
let voteCount = 0
let selectedIdx = null
for (const [idx, value] of this.votes) {
if (value > voteCount) {
voteCount = value
selectedIdx = idx
}
}
const msg = this.voteTemplate({ selectedIdx, voteCount })
await client.say(channel, msg)
// Index spaces starting with 1
this.emit('setListeningView', selectedIdx - 1)
this.votes = new Map()
}
onMsg(msg) {
const { grid, vote } = this.config
if (!vote.interval) {
return
}
const match = msg.messageText.match(VOTE_RE)
if (!match) {
return
}
let idx
try {
idx = Number(match[1])
} catch (err) {
return
}
this.votes.set(idx, (this.votes.get(idx) || 0) + 1)
}
}

View File

@@ -1,163 +0,0 @@
import EventEmitter from 'events'
import { randomBytes, scrypt as scryptCb } from 'crypto'
import { promisify } from 'util'
import { validRoles } from '../roles'
const scrypt = promisify(scryptCb)
import baseX from 'base-x'
const base62 = baseX(
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',
)
function rand62(len) {
return base62.encode(randomBytes(len))
}
async function hashToken62(token, salt) {
const hashBuffer = await scrypt(token, salt, 24)
return base62.encode(hashBuffer)
}
// Wrapper for state data to facilitate role-scoped data access.
export class StateWrapper extends EventEmitter {
constructor(value) {
super()
this._value = value
}
toJSON() {
return '<state data>'
}
view(role) {
const {
config,
auth,
streams,
customStreams,
views,
streamdelay,
} = this._value
const state = {
config,
streams,
customStreams,
views,
streamdelay,
}
if (role === 'admin') {
state.auth = auth
}
return state
}
update(value) {
this._value = { ...this._value, ...value }
this.emit('state', this)
}
// Unprivileged getter
get info() {
return this.view()
}
}
export class Auth extends EventEmitter {
constructor({ adminUsername, adminPassword, persistData, logEnabled }) {
super()
this.adminUsername = adminUsername
this.adminPassword = adminPassword
this.logEnabled = logEnabled || false
this.salt = persistData?.salt || rand62(16)
this.tokensById = new Map()
this.tokensByHash = new Map()
for (const token of persistData?.tokens ?? []) {
this.tokensById.set(token.id, token)
this.tokensByHash.set(token.tokenHash, token)
}
}
getPersistData() {
return {
salt: this.salt,
tokens: [...this.tokensById.values()],
}
}
getState() {
const toTokenInfo = ({ id, name, role }) => ({ id, name, role })
return {
invites: [...this.tokensById.values()]
.filter((t) => t.kind === 'invite')
.map(toTokenInfo),
sessions: [...this.tokensById.values()]
.filter((t) => t.kind === 'session')
.map(toTokenInfo),
}
}
emitState() {
this.emit('state', this.getState())
}
admin() {
return { id: 'admin', kind: 'admin', name: 'admin', role: 'admin' }
}
async validateToken(secret) {
const tokenHash = await hashToken62(secret, this.salt)
const tokenData = this.tokensByHash.get(tokenHash)
if (!tokenData) {
return null
}
return {
id: tokenData.id,
kind: tokenData.kind,
role: tokenData.role,
name: tokenData.name,
}
}
async createToken({ kind, role, name }) {
if (!validRoles.has(role)) {
throw new Error(`invalid role: ${role}`)
}
let id = rand62(8)
// Regenerate in case of an id collision
while (this.tokensById.has(id)) {
id = rand62(8)
}
const secret = rand62(24)
const tokenHash = await hashToken62(secret, this.salt)
const tokenData = {
id,
tokenHash,
kind,
role,
name,
}
this.tokensById.set(id, tokenData)
this.tokensByHash.set(tokenHash, tokenData)
this.emitState()
if (this.logEnabled) {
console.log(`Created ${kind} token:`, { id, role, name })
}
return { id, secret }
}
deleteToken(tokenId) {
const tokenData = this.tokensById.get(tokenId)
if (!tokenData) {
return
}
this.tokensById.delete(tokenData.id)
this.tokensByHash.delete(tokenData.tokenHash)
this.emitState()
}
}

View File

@@ -1,31 +0,0 @@
import { app } from 'electron'
import { promises as fsPromises, stat } from 'fs'
import path from 'path'
import throttle from 'lodash/throttle'
const stateFilePath = path.join(app.getPath('userData'), 'streamwall.json')
let lastState = {}
async function _save(partialState) {
const state = { ...lastState, ...partialState }
lastState = state
const data = JSON.stringify(state)
await fsPromises.writeFile(stateFilePath, data)
}
export const save = throttle(_save, 501)
export async function load() {
try {
const data = await fsPromises.readFile(stateFilePath)
return JSON.parse(data)
} catch (err) {
if (err.code === 'ENOENT') {
// Ignore missing file.
} else {
console.warn('error reading persisted state:', err)
}
return {}
}
}

View File

@@ -1,285 +0,0 @@
import { promisify } from 'util'
import url from 'url'
import http from 'http'
import https from 'https'
import simpleCert from 'node-simple-cert'
import Koa from 'koa'
import basicAuth 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'
import WebSocket from 'ws'
import * as Y from 'yjs'
import { create as createJSONDiffPatch } from 'jsondiffpatch'
import { roleCan } from '../roles'
export const SESSION_COOKIE_NAME = 's'
const stateDiff = createJSONDiffPatch({
objectHash: (obj, idx) => obj._id || `$$index:${idx}`,
// Disable text diffing, both because it's overkill, and because it can crash with emojis (https://github.com/google/diff-match-patch/issues/68)
textDiff: {
minLength: Infinity,
},
})
function initApp({
auth,
baseURL,
webDistPath,
clientState,
logEnabled,
onMessage,
stateDoc,
}) {
const expectedOrigin = new URL(baseURL).origin
const sockets = new Set()
const app = new Koa()
// silence koa printing errors when websockets close early
app.silent = true
app.use(views(webDistPath, { extension: 'ejs' }))
app.use(serveStatic(webDistPath))
app.use(websocket())
app.use(
route.get('/invite/:token', async (ctx, token) => {
const tokenInfo = await auth.validateToken(token)
if (!tokenInfo || tokenInfo.kind !== 'invite') {
return ctx.throw(403)
}
const { secret } = await auth.createToken({
kind: 'session',
name: tokenInfo.name,
role: tokenInfo.role,
})
ctx.cookies.set(SESSION_COOKIE_NAME, secret, {
maxAge: 1 * 365 * 24 * 60 * 60 * 1000,
overwrite: true,
})
await auth.deleteToken(tokenInfo.id)
ctx.redirect('/')
}),
)
const basicAuthMiddleware = basicAuth({
name: auth.adminUsername,
pass: auth.adminPassword,
})
app.use(async (ctx, next) => {
const sessionCookie = ctx.cookies.get(SESSION_COOKIE_NAME)
if (sessionCookie) {
const tokenInfo = await auth.validateToken(sessionCookie)
if (tokenInfo && tokenInfo.kind === 'session') {
ctx.state.identity = tokenInfo
await next()
return
}
}
await basicAuthMiddleware(ctx, async () => {
ctx.state.identity = auth.admin()
await next()
})
})
app.use(
route.get('/', async (ctx) => {
await ctx.render('control', {
wsEndpoint: url.resolve(baseURL, 'ws').replace(/^http/, 'ws'),
role: ctx.state.identity.role,
})
}),
)
app.use(
route.get('/ws', async (ctx) => {
if (ctx.ws) {
if (ctx.headers.origin !== expectedOrigin) {
ctx.status = 403
return
}
const { identity } = ctx.state
const ws = await ctx.ws()
const client = {
ws,
lastState: null,
identity,
}
sockets.add(client)
ws.binaryType = 'arraybuffer'
const pingInterval = setInterval(() => {
ws.ping()
}, 20 * 1000)
ws.on('close', () => {
sockets.delete(ws)
clearInterval(pingInterval)
})
ws.on('message', (rawData) => {
let msg
const respond = (responseData) => {
if (ws.readyState !== WebSocket.OPEN) {
return
}
ws.send(
JSON.stringify({
...responseData,
response: true,
id: msg && msg.id,
}),
)
}
if (rawData instanceof ArrayBuffer) {
if (!roleCan(identity.role, 'mutate-state-doc')) {
if (logEnabled) {
console.warn(
`Unauthorized attempt to edit state doc by "${identity.name}"`,
)
}
respond({
error: 'unauthorized',
})
return
}
Y.applyUpdate(stateDoc, new Uint8Array(rawData))
return
}
try {
msg = JSON.parse(rawData)
} catch (err) {
if (logEnabled) {
console.warn('received unexpected ws data:', rawData)
}
return
}
try {
if (!roleCan(identity.role, msg.type)) {
if (logEnabled) {
console.warn(
`Unauthorized attempt to "${msg.type}" by "${identity.name}"`,
)
}
respond({
error: 'unauthorized',
})
return
}
onMessage(msg, respond)
} catch (err) {
console.error('failed to handle ws message:', data, err)
}
})
const state = clientState.view(identity.role)
ws.send(JSON.stringify({ type: 'state', state }))
ws.send(Y.encodeStateAsUpdate(stateDoc))
client.lastState = state
return
}
ctx.status = 404
}),
)
clientState.on('state', (state) => {
for (const client of sockets) {
if (client.ws.readyState !== WebSocket.OPEN) {
continue
}
const stateView = state.view(client.identity.role)
const delta = stateDiff.diff(client.lastState, stateView)
client.lastState = stateView
if (!delta) {
continue
}
client.ws.send(JSON.stringify({ type: 'state-delta', delta }))
}
})
stateDoc.on('update', (update) => {
for (const client of sockets) {
if (client.ws.readyState !== WebSocket.OPEN) {
continue
}
client.ws.send(update)
}
})
auth.on('state', (state) => {
const tokenIds = new Set(state.sessions.map((t) => t.id))
for (const client of sockets) {
if (client.identity.role === 'admin') {
continue
}
if (!tokenIds.has(client.identity.id)) {
client.ws.close()
}
}
})
return { app }
}
export default async function initWebServer({
certDir,
certProduction,
email,
url: baseURL,
hostname: overrideHostname,
port: overridePort,
webDistPath,
auth,
logEnabled,
clientState,
onMessage,
stateDoc,
}) {
console.debug('Parsing URL:', baseURL)
let { protocol, hostname, port } = new URL(baseURL)
if (!port) {
port = protocol === 'https:' ? 443 : 80
}
if (overridePort) {
port = overridePort
}
console.debug('Initializing web server:', { hostname, port })
const { app } = initApp({
auth,
baseURL,
webDistPath,
clientState,
logEnabled,
onMessage,
stateDoc,
})
let server
if (protocol === 'https:' && certDir) {
const { key, cert } = await simpleCert({
dataDir: certDir,
commonName: hostname,
email,
production: certProduction,
serverHost: overrideHostname || hostname,
})
server = https.createServer({ key, cert }, app.callback())
} else {
server = http.createServer(app.callback())
}
const listen = promisify(server.listen).bind(server)
await listen(port, overrideHostname || hostname)
return { server }
}

View File

@@ -1,392 +0,0 @@
// Mock koa middleware that require built statics
jest.mock('koa-static', () => () => (ctx, next) => next())
jest.mock('koa-views', () => () => (ctx, next) => {
ctx.render = async () => {
ctx.body = 'mock'
}
return next()
})
import { on, once } from 'events'
import supertest from 'supertest'
import * as Y from 'yjs'
import WebSocket from 'ws'
import { patch as patchJSON } from 'jsondiffpatch'
import { Auth, StateWrapper } from './auth'
import initWebServer, { SESSION_COOKIE_NAME } from './server'
import base from 'base-x'
describe('streamwall server', () => {
const adminUsername = 'admin'
const adminPassword = 'password'
const hostname = 'localhost'
const port = 8081
const baseURL = `http://${hostname}:${port}`
let auth
let clientState
let server
let request
let stateDoc
let onMessage
let onMessageCalled
let sockets
beforeEach(async () => {
sockets = []
auth = new Auth({
adminUsername,
adminPassword,
})
clientState = new StateWrapper({
config: {
width: 1920,
height: 1080,
gridCount: 6,
},
auth: auth.getState(),
streams: [],
customStreams: [],
views: [],
streamdelay: null,
})
stateDoc = new Y.Doc()
onMessageCalled = new Promise((resolve) => {
onMessage = jest.fn(resolve)
})
;({ server } = await initWebServer({
url: baseURL,
hostname,
port,
auth,
clientState,
onMessage,
stateDoc,
}))
request = supertest(server)
auth.on('state', (authState) => {
clientState.update({ auth: authState })
})
})
afterEach(() => {
server.close()
for (const ws of sockets) {
ws.close()
}
})
function socket(options) {
const ws = new WebSocket(`ws://${hostname}:${port}/ws`, [], {
...options,
origin: baseURL,
})
sockets.push(ws)
const msgs = on(ws, 'message')
async function recvMsg() {
const {
value: [data, isBinary],
} = await msgs.next()
if (isBinary) {
return data
}
return JSON.parse(data.toString())
}
function sendMsg(msg) {
ws.send(JSON.stringify(msg))
}
return { ws, recvMsg, sendMsg }
}
function socketFromSecret(secret) {
return socket({
headers: { Cookie: `${SESSION_COOKIE_NAME}=${secret}` },
})
}
describe('basic auth', () => {
it('rejects missing credentials', async () => {
await request.get('/').expect(401)
})
it('rejects empty credentials', async () => {
await request.get('/').auth('', '').expect(401)
})
it('rejects incorrect credentials', async () => {
await request.get('/').auth('wrong', 'creds').expect(401)
})
it('accepts correct credentials', async () => {
await request.get('/').auth(adminUsername, adminPassword).expect(200)
})
})
describe('invite urls', () => {
it('rejects missing token', async () => {
await request.get('/invite/').expect(401)
})
it('rejects invalid token', async () => {
await request.get('/invite/badtoken').expect(403)
})
it('rejects token of incorrect type', async () => {
const { secret } = await auth.createToken({
kind: 'session',
role: 'operator',
name: 'test',
})
await request.get(`/invite/${secret}`).expect(403)
})
it('accepts valid token and creates session cookie', async () => {
const { secret } = await auth.createToken({
kind: 'invite',
role: 'operator',
name: 'test',
})
expect(auth.getState().invites.length).toBe(1)
await request.get(`/invite/${secret}`).expect(302)
expect(auth.getState().invites.length).toBe(0)
})
})
describe('token access', () => {
it('ignores empty tokens', async () => {
await request
.get('/')
.set('Cookie', `${SESSION_COOKIE_NAME}=`)
.expect(401)
})
it('ignores invite tokens', async () => {
const { secret } = await auth.createToken({
kind: 'invite',
role: 'operator',
name: 'test',
})
await request
.get('/')
.set('Cookie', `${SESSION_COOKIE_NAME}=${secret}`)
.expect(401)
})
it('accepts valid tokens', async () => {
const { secret } = await auth.createToken({
kind: 'session',
role: 'operator',
name: 'test',
})
await request
.get('/')
.set('Cookie', `${SESSION_COOKIE_NAME}=${secret}`)
.expect(200)
})
it('disconnects websocket on token deletion', async () => {
const { id: tokenId, secret } = await auth.createToken({
kind: 'session',
role: 'operator',
name: 'test',
})
const { recvMsg, ws } = await socketFromSecret(secret)
await recvMsg()
await recvMsg()
expect(ws.readyState === WebSocket.OPEN)
auth.deleteToken(tokenId)
await once(ws, 'close')
})
})
describe('admin role', () => {
it('can view tokens', async () => {
await auth.createToken({
kind: 'invite',
role: 'operator',
name: 'test',
})
expect(auth.getState().invites.length).toBe(1)
const { recvMsg } = await socket({
auth: `${adminUsername}:${adminPassword}`,
})
const firstMsg = await recvMsg()
expect(firstMsg.type).toBe('state')
expect(firstMsg.state).toHaveProperty('auth')
expect(firstMsg.state.auth.invites).toHaveLength(1)
})
it('receives token state updates', async () => {
const { recvMsg } = await socket({
auth: `${adminUsername}:${adminPassword}`,
})
const { state } = await recvMsg()
expect(state.auth.invites).toHaveLength(0)
await recvMsg()
await auth.createToken({
kind: 'invite',
role: 'operator',
name: 'test',
})
const stateDelta = await recvMsg()
expect(stateDelta.type).toBe('state-delta')
expect(stateDelta.delta).toHaveProperty('auth')
const updatedState = patchJSON(state, stateDelta.delta)
expect(updatedState).toHaveProperty('auth')
expect(updatedState.auth.invites).toHaveLength(1)
})
it('can create an invite', async () => {
const { recvMsg, sendMsg } = await socket({
auth: `${adminUsername}:${adminPassword}`,
})
await recvMsg()
await recvMsg()
expect(auth.getState().invites.length).toBe(0)
sendMsg({ type: 'create-invite', role: 'operator', name: 'test' })
await onMessageCalled
expect(
expect(onMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'create-invite',
role: 'operator',
name: 'test',
}),
expect.any(Function),
),
)
})
})
describe('operator role', () => {
let secret
beforeEach(async () => [
({ secret } = await auth.createToken({
kind: 'session',
role: 'operator',
name: 'test',
})),
])
it('cannot view tokens', async () => {
const { recvMsg } = await socketFromSecret(secret)
const firstMsg = await recvMsg()
expect(firstMsg.type).toBe('state')
expect(firstMsg.state).not.toHaveProperty('auth')
})
it('cannot create invites', async () => {
const { recvMsg, sendMsg } = await socketFromSecret(secret)
await recvMsg()
await recvMsg()
sendMsg({ type: 'create-invite', role: 'operator', name: 'test' })
const resp = await recvMsg()
expect(resp.response).toBe(true)
expect(resp.error).toBe('unauthorized')
})
it('does not receive token state updates', async () => {
// FIXME: a bit difficult to test the lack of a state update sent; currently, this test triggers a second state update and assumes that if it receives it, the state update for the "auth" property was never sent.
const { recvMsg } = await socketFromSecret(secret)
await recvMsg()
await recvMsg()
clientState.update({ streams: [{ _id: 'tes' }] })
const testUpdate = await recvMsg()
expect(testUpdate.type).toBe('state-delta')
expect(testUpdate.delta).toHaveProperty('streams')
expect(testUpdate.delta).not.toHaveProperty('auth')
await auth.createToken({
kind: 'invite',
role: 'operator',
name: 'test',
})
clientState.update({ streams: [{ _id: 'tes2' }] })
const testUpdate2 = await recvMsg()
expect(testUpdate2.type).toBe('state-delta')
expect(testUpdate2.delta).toHaveProperty('streams')
expect(testUpdate2.delta).not.toHaveProperty('auth')
})
it('can change listening view', async () => {
const { recvMsg, sendMsg } = await socketFromSecret(secret)
await recvMsg()
await recvMsg()
sendMsg({ type: 'set-listening-view', viewIdx: 7 })
await onMessageCalled
expect(
expect(onMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: 'set-listening-view',
viewIdx: 7,
}),
expect.any(Function),
),
)
})
it('can mutate state doc', async () => {
const { ws, recvMsg } = await socketFromSecret(secret)
await recvMsg()
const doc = new Y.Doc()
const yUpdate = await recvMsg()
Y.applyUpdate(doc, new Uint8Array(yUpdate), 'server')
const updateEvent = on(doc, 'update')
doc.getMap('views').set(0, new Y.Map())
const {
value: [updateToSend],
} = await updateEvent.next()
ws.send(updateToSend)
const yUpdate2 = await recvMsg()
expect(yUpdate2).toBeInstanceOf(Buffer)
})
})
describe('monitor role', () => {
let secret
beforeEach(async () => [
({ secret } = await auth.createToken({
kind: 'session',
role: 'monitor',
name: 'test',
})),
])
it('cannot view tokens', async () => {
const { recvMsg } = await socketFromSecret(secret)
const firstMsg = await recvMsg()
expect(firstMsg.type).toBe('state')
expect(firstMsg.state).not.toHaveProperty('auth')
})
it('cannot change listening view', async () => {
const { recvMsg, sendMsg } = await socketFromSecret(secret)
await recvMsg()
await recvMsg()
sendMsg({ type: 'set-listening-view', viewIdx: 7 })
const resp = await recvMsg()
expect(resp.response).toBe(true)
expect(resp.error).toBe('unauthorized')
})
it('cannot mutate state doc', async () => {
const { ws, recvMsg } = await socketFromSecret(secret)
await recvMsg()
await recvMsg()
ws.send(new ArrayBuffer())
const resp = await recvMsg()
expect(resp.response).toBe(true)
expect(resp.error).toBe('unauthorized')
})
})
})

View File

@@ -1,203 +0,0 @@
import isEqual from 'lodash/isEqual'
import { Machine, assign } from 'xstate'
import { ensureValidURL } from '../util'
const viewStateMachine = Machine(
{
id: 'view',
initial: 'empty',
context: {
id: null,
view: null,
pos: null,
content: null,
options: null,
info: {},
},
on: {
DISPLAY: 'displaying',
},
states: {
empty: {},
displaying: {
id: 'displaying',
initial: 'loading',
entry: [
assign({
pos: (context, event) => event.pos,
content: (context, event) => event.content,
}),
'offscreenView',
],
on: {
DISPLAY: {
actions: assign({
pos: (context, event) => event.pos,
}),
cond: 'contentUnchanged',
},
OPTIONS: {
actions: [
assign({
options: (context, event) => event.options,
}),
'sendViewOptions',
],
cond: 'optionsChanged',
},
RELOAD: '.loading',
DEVTOOLS: {
actions: 'openDevTools',
},
VIEW_ERROR: '.error',
VIEW_INFO: {
actions: assign({
info: (context, event) => event.info,
}),
},
},
states: {
loading: {
initial: 'navigate',
states: {
navigate: {
invoke: {
src: 'loadPage',
onDone: {
target: 'waitForInit',
},
onError: {
target: '#view.displaying.error',
},
},
},
waitForInit: {
on: {
VIEW_INIT: 'waitForVideo',
},
},
waitForVideo: {
on: {
VIEW_LOADED: '#view.displaying.running',
},
},
},
},
running: {
type: 'parallel',
entry: 'positionView',
on: {
DISPLAY: [
// Noop if nothing changed.
{ cond: 'contentPosUnchanged' },
{
actions: [
assign({
pos: (context, event) => event.pos,
}),
'positionView',
],
cond: 'contentUnchanged',
},
],
},
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: {
entry: 'logError',
},
},
},
},
},
{
actions: {
logError: (context, event) => {
console.warn(event)
},
muteAudio: (context, event) => {
context.view.webContents.audioMuted = true
},
unmuteAudio: (context, event) => {
context.view.webContents.audioMuted = false
},
openDevTools: (context, event) => {
const { view } = context
const { inWebContents } = event
view.webContents.setDevToolsWebContents(inWebContents)
view.webContents.openDevTools({ mode: 'detach' })
},
sendViewOptions: (context, event) => {
const { view } = context
view.webContents.send('options', event.options)
},
},
guards: {
contentUnchanged: (context, event) => {
return isEqual(context.content, event.content)
},
contentPosUnchanged: (context, event) => {
return (
isEqual(context.content, event.content) &&
isEqual(context.pos, event.pos)
)
},
optionsChanged: (context, event) => {
return !isEqual(context.options, event.options)
},
},
services: {
loadPage: async (context, event) => {
const { content, view } = context
ensureValidURL(content.url)
const wc = view.webContents
wc.audioMuted = true
if (/\.m3u8?$/.test(content.url)) {
wc.loadFile('playHLS.html', { query: { src: content.url } })
} else {
wc.loadURL(content.url)
}
},
},
},
)
export default viewStateMachine

View File

@@ -1,32 +0,0 @@
const operatorActions = new Set([
'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',
])
const monitorActions = new Set(['set-view-blurred', 'set-stream-censored'])
export const validRoles = new Set(['admin', 'operator', 'monitor'])
export function roleCan(role, action) {
if (role === 'admin') {
return true
}
if (role === 'operator' && operatorActions.has(action)) {
return true
}
if (role === 'monitor' && monitorActions.has(action)) {
return true
}
return false
}

View File

@@ -1,39 +0,0 @@
import { roleCan } from './roles.js';
describe('roleCan', () => {
it('should return true for admin role regardless of action', () => {
expect(roleCan('admin', 'any-action')).toBe(true);
});
it('should return true for operator role and valid action', () => {
expect(roleCan('operator', 'set-listening-view')).toBe(true);
});
it('should return false for operator role and invalid action', () => {
expect(roleCan('operator', 'invalid-action')).toBe(false);
});
it('should return false for operator role and un-granted action', () => {
expect(roleCan('operator', 'dev-tools')).toBe(false);
});
it('should return true for monitor role and valid action', () => {
expect(roleCan('monitor', 'set-view-blurred')).toBe(true);
});
it('should return false for monitor role and invalid action', () => {
expect(roleCan('monitor', 'invalid-action')).toBe(false);
});
it('should return false for monitor role and un-granted action', () => {
expect(roleCan('monitor', 'set-listening-view')).toBe(false);
});
it('should return false for invalid role regardless of action', () => {
expect(roleCan('invalid-role', 'any-action')).toBe(false);
});
it('should return false for invalid role and valid action', () => {
expect(roleCan('invalid-role', 'set-listening-view')).toBe(false);
});
});

Binary file not shown.

Binary file not shown.

View File

@@ -1 +0,0 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="exchange-alt" class="svg-inline--fa fa-exchange-alt fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M0 168v-16c0-13.255 10.745-24 24-24h360V80c0-21.367 25.899-32.042 40.971-16.971l80 80c9.372 9.373 9.372 24.569 0 33.941l-80 80C409.956 271.982 384 261.456 384 240v-48H24c-13.255 0-24-10.745-24-24zm488 152H128v-48c0-21.314-25.862-32.08-40.971-16.971l-80 80c-9.372 9.373-9.372 24.569 0 33.941l80 80C102.057 463.997 128 453.437 128 432v-48h360c13.255 0 24-10.745 24-24v-16c0-13.255-10.745-24-24-24z"></path></svg>

Before

Width:  |  Height:  |  Size: 639 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M504 256C504 119 393 8 256 8S8 119 8 256c0 123.78 90.69 226.38 209.25 245V327.69h-63V256h63v-54.64c0-62.15 37-96.48 93.67-96.48 27.14 0 55.52 4.84 55.52 4.84v61h-31.28c-30.8 0-40.41 19.12-40.41 38.73V256h68.78l-11 71.69h-57.78V501C413.31 482.38 504 379.78 504 256z"/></svg>

Before

Width:  |  Height:  |  Size: 344 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M224.1 141c-63.6 0-114.9 51.3-114.9 114.9s51.3 114.9 114.9 114.9S339 319.5 339 255.9 287.7 141 224.1 141zm0 189.6c-41.1 0-74.7-33.5-74.7-74.7s33.5-74.7 74.7-74.7 74.7 33.5 74.7 74.7-33.6 74.7-74.7 74.7zm146.4-194.3c0 14.9-12 26.8-26.8 26.8-14.9 0-26.8-12-26.8-26.8s12-26.8 26.8-26.8 26.8 12 26.8 26.8zm76.1 27.2c-1.7-35.9-9.9-67.7-36.2-93.9-26.2-26.2-58-34.4-93.9-36.2-37-2.1-147.9-2.1-184.9 0-35.8 1.7-67.6 9.9-93.9 36.1s-34.4 58-36.2 93.9c-2.1 37-2.1 147.9 0 184.9 1.7 35.9 9.9 67.7 36.2 93.9s58 34.4 93.9 36.2c37 2.1 147.9 2.1 184.9 0 35.9-1.7 67.7-9.9 93.9-36.2 26.2-26.2 34.4-58 36.2-93.9 2.1-37 2.1-147.8 0-184.8zM398.8 388c-7.8 19.6-22.9 34.7-42.6 42.6-29.5 11.7-99.5 9-132.1 9s-102.7 2.6-132.1-9c-19.6-7.8-34.7-22.9-42.6-42.6-11.7-29.5-9-99.5-9-132.1s-2.6-102.7 9-132.1c7.8-19.6 22.9-34.7 42.6-42.6 29.5-11.7 99.5-9 132.1-9s102.7-2.6 132.1 9c19.6 7.8 34.7 22.9 42.6 42.6 11.7 29.5 9 99.5 9 132.1s2.7 102.7-9 132.1z"/></svg>

Before

Width:  |  Height:  |  Size: 1002 B

View File

@@ -1 +0,0 @@
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="life-ring" class="svg-inline--fa fa-life-ring fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256 504c136.967 0 248-111.033 248-248S392.967 8 256 8 8 119.033 8 256s111.033 248 248 248zm-103.398-76.72l53.411-53.411c31.806 13.506 68.128 13.522 99.974 0l53.411 53.411c-63.217 38.319-143.579 38.319-206.796 0zM336 256c0 44.112-35.888 80-80 80s-80-35.888-80-80 35.888-80 80-80 80 35.888 80 80zm91.28 103.398l-53.411-53.411c13.505-31.806 13.522-68.128 0-99.974l53.411-53.411c38.319 63.217 38.319 143.579 0 206.796zM359.397 84.72l-53.411 53.411c-31.806-13.505-68.128-13.522-99.973 0L152.602 84.72c63.217-38.319 143.579-38.319 206.795 0zM84.72 152.602l53.411 53.411c-13.506 31.806-13.522 68.128 0 99.974L84.72 359.398c-38.319-63.217-38.319-143.579 0-206.796z"></path></svg>

Before

Width:  |  Height:  |  Size: 895 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M370 63.6C331.4 22.6 280.5 0 226.6 0 111.9 0 18.5 96.2 18.5 214.4c0 75.1 57.8 159.8 82.7 192.7C137.8 455.5 192.6 512 226.6 512c41.6 0 112.9-94.2 120.9-105 24.6-33.1 82-118.3 82-192.6 0-56.5-21.1-110.1-59.5-150.8zM226.6 493.9c-42.5 0-190-167.3-190-279.4 0-107.4 83.9-196.3 190-196.3 100.8 0 184.7 89 184.7 196.3.1 112.1-147.4 279.4-184.7 279.4zM338 206.8c0 59.1-51.1 109.7-110.8 109.7-100.6 0-150.7-108.2-92.9-181.8v.4c0 24.5 20.1 44.4 44.8 44.4 24.7 0 44.8-19.9 44.8-44.4 0-18.2-11.1-33.8-26.9-40.7 76.6-19.2 141 39.3 141 112.4z"/></svg>

Before

Width:  |  Height:  |  Size: 608 B

View File

@@ -1 +0,0 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="redo-alt" class="svg-inline--fa fa-redo-alt fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M256.455 8c66.269.119 126.437 26.233 170.859 68.685l35.715-35.715C478.149 25.851 504 36.559 504 57.941V192c0 13.255-10.745 24-24 24H345.941c-21.382 0-32.09-25.851-16.971-40.971l41.75-41.75c-30.864-28.899-70.801-44.907-113.23-45.273-92.398-.798-170.283 73.977-169.484 169.442C88.764 348.009 162.184 424 256 424c41.127 0 79.997-14.678 110.629-41.556 4.743-4.161 11.906-3.908 16.368.553l39.662 39.662c4.872 4.872 4.631 12.815-.482 17.433C378.202 479.813 319.926 504 256 504 119.034 504 8.001 392.967 8 256.002 7.999 119.193 119.646 7.755 256.455 8z"></path></svg>

Before

Width:  |  Height:  |  Size: 781 B

View File

@@ -1 +0,0 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="sync-alt" class="svg-inline--fa fa-sync-alt fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M370.72 133.28C339.458 104.008 298.888 87.962 255.848 88c-77.458.068-144.328 53.178-162.791 126.85-1.344 5.363-6.122 9.15-11.651 9.15H24.103c-7.498 0-13.194-6.807-11.807-14.176C33.933 94.924 134.813 8 256 8c66.448 0 126.791 26.136 171.315 68.685L463.03 40.97C478.149 25.851 504 36.559 504 57.941V192c0 13.255-10.745 24-24 24H345.941c-21.382 0-32.09-25.851-16.971-40.971l41.75-41.749zM32 296h134.059c21.382 0 32.09 25.851 16.971 40.971l-41.75 41.75c31.262 29.273 71.835 45.319 114.876 45.28 77.418-.07 144.315-53.144 162.787-126.849 1.344-5.363 6.122-9.15 11.651-9.15h57.304c7.498 0 13.194 6.807 11.807 14.176C478.067 417.076 377.187 504 256 504c-66.448 0-126.791-26.136-171.315-68.685L48.97 471.03C33.851 486.149 8 475.441 8 454.059V320c0-13.255 10.745-24 24-24z"></path></svg>

Before

Width:  |  Height:  |  Size: 998 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><!--! Font Awesome Pro 6.1.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path d="M448,209.91a210.06,210.06,0,0,1-122.77-39.25V349.38A162.55,162.55,0,1,1,185,188.31V278.2a74.62,74.62,0,1,0,52.23,71.18V0l88,0a121.18,121.18,0,0,0,1.86,22.17h0A122.18,122.18,0,0,0,381,102.39a121.43,121.43,0,0,0,67,20.14Z"/></svg>

Before

Width:  |  Height:  |  Size: 467 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M391.17,103.47H352.54v109.7h38.63ZM285,103H246.37V212.75H285ZM120.83,0,24.31,91.42V420.58H140.14V512l96.53-91.42h77.25L487.69,256V0ZM449.07,237.75l-77.22,73.12H294.61l-67.6,64v-64H140.14V36.58H449.07Z"/></svg>

Before

Width:  |  Height:  |  Size: 281 B

View File

@@ -1 +0,0 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="video-slash" class="svg-inline--fa fa-video-slash fa-w-20" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M633.8 458.1l-55-42.5c15.4-1.4 29.2-13.7 29.2-31.1v-257c0-25.5-29.1-40.4-50.4-25.8L448 177.3v137.2l-32-24.7v-178c0-26.4-21.4-47.8-47.8-47.8H123.9L45.5 3.4C38.5-2 28.5-.8 23 6.2L3.4 31.4c-5.4 7-4.2 17 2.8 22.4L42.7 82 416 370.6l178.5 138c7 5.4 17 4.2 22.5-2.8l19.6-25.3c5.5-6.9 4.2-17-2.8-22.4zM32 400.2c0 26.4 21.4 47.8 47.8 47.8h288.4c11.2 0 21.4-4 29.6-10.5L32 154.7v245.5z"></path></svg>

Before

Width:  |  Height:  |  Size: 617 B

View File

@@ -1 +0,0 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="volume-up" class="svg-inline--fa fa-volume-up fa-w-18" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path fill="currentColor" d="M215.03 71.05L126.06 160H24c-13.26 0-24 10.74-24 24v144c0 13.25 10.74 24 24 24h102.06l88.97 88.95c15.03 15.03 40.97 4.47 40.97-16.97V88.02c0-21.46-25.96-31.98-40.97-16.97zm233.32-51.08c-11.17-7.33-26.18-4.24-33.51 6.95-7.34 11.17-4.22 26.18 6.95 33.51 66.27 43.49 105.82 116.6 105.82 195.58 0 78.98-39.55 152.09-105.82 195.58-11.17 7.32-14.29 22.34-6.95 33.5 7.04 10.71 21.93 14.56 33.51 6.95C528.27 439.58 576 351.33 576 256S528.27 72.43 448.35 19.97zM480 256c0-63.53-32.06-121.94-85.77-156.24-11.19-7.14-26.03-3.82-33.12 7.46s-3.78 26.21 7.41 33.36C408.27 165.97 432 209.11 432 256s-23.73 90.03-63.48 115.42c-11.19 7.14-14.5 22.07-7.41 33.36 6.51 10.36 21.12 15.14 33.12 7.46C447.94 377.94 480 319.54 480 256zm-141.77-76.87c-11.58-6.33-26.19-2.16-32.61 9.45-6.39 11.61-2.16 26.2 9.45 32.61C327.98 228.28 336 241.63 336 256c0 14.38-8.02 27.72-20.92 34.81-11.61 6.41-15.84 21-9.45 32.61 6.43 11.66 21.05 15.8 32.61 9.45 28.23-15.55 45.77-45 45.77-76.88s-17.54-61.32-45.78-76.86z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1 +0,0 @@
<svg aria-hidden="true" focusable="false" data-prefix="far" data-icon="window-maximize" class="svg-inline--fa fa-window-maximize fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M464 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h416c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48zm0 394c0 3.3-2.7 6-6 6H54c-3.3 0-6-2.7-6-6V192h416v234z"></path></svg>

Before

Width:  |  Height:  |  Size: 410 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path d="M549.655 124.083c-6.281-23.65-24.787-42.276-48.284-48.597C458.781 64 288 64 288 64S117.22 64 74.629 75.486c-23.497 6.322-42.003 24.947-48.284 48.597-11.412 42.867-11.412 132.305-11.412 132.305s0 89.438 11.412 132.305c6.281 23.65 24.787 41.5 48.284 47.821C117.22 448 288 448 288 448s170.78 0 213.371-11.486c23.497-6.321 42.003-24.171 48.284-47.821 11.412-42.867 11.412-132.305 11.412-132.305s0-89.438-11.412-132.305zm-317.51 213.508V175.185l142.739 81.205-142.739 81.201z"/></svg>

Before

Width:  |  Height:  |  Size: 550 B

View File

@@ -1,17 +0,0 @@
import { ensureValidURL } from './util'
describe('ensureValidURL', () => {
it('should not throw an error for valid http and https URLs', () => {
expect(() => ensureValidURL('http://example.com')).not.toThrow()
expect(() => ensureValidURL('https://example.com')).not.toThrow()
})
it('should throw an error for non-http and non-https URLs', () => {
expect(() => ensureValidURL('ftp://example.com')).toThrow()
expect(() => ensureValidURL('file://example.com')).toThrow()
})
it('should throw an error for invalid URLs', () => {
expect(() => ensureValidURL('invalid')).toThrow()
})
})

View File

@@ -1,44 +0,0 @@
import { hashText, idColor } from './colors'
import Color from 'color'
describe('colors.js tests', () => {
describe('hashText', () => {
it('should return a number within the provided range', () => {
const text = 'test'
const range = 100
const result = hashText(text, range)
expect(typeof result).toBe('number')
expect(result).toBeGreaterThanOrEqual(0)
expect(result).toBeLessThan(range)
})
})
describe('idColor', () => {
it('should return a Color object', () => {
const id = 'test'
const result = idColor(id)
expect(result).toBeInstanceOf(Color)
})
it('should return white color for empty id', () => {
const id = ''
const result = idColor(id)
expect(result.hex()).toBe('#FFFFFF')
})
it('should generate the same color for the same id', () => {
const id = 'test'
const result1 = idColor(id)
const result2 = idColor(id)
expect(result1.hex()).toBe(result2.hex())
})
it('should generate different colors for different ids', () => {
const id1 = 'test1'
const id2 = 'test2'
const result1 = idColor(id1)
const result2 = idColor(id2)
expect(result1.hex()).not.toBe(result2.hex())
})
})
})

View File

@@ -1,21 +0,0 @@
<!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.js"
type="module"
id="main-script"
data-ws-endpoint="<%= wsEndpoint %>"
data-role="<%= role %>"
crossorigin
></script>
</body>
</html>

View File

@@ -1,50 +0,0 @@
import { filterStreams, useYDoc, useStreamwallConnection } from './control.js'
// import { renderHook, act } from '@testing-library/react-hooks'
describe("control test always passes", () => {
it("always passes", () => {
expect(true).toBe(true);
});
});
// describe('filterStreams', () => {
// it('should correctly filter live and other streams', () => {
// const streams = [
// { kind: 'video', status: 'Live' },
// { kind: 'audio', status: 'Offline' },
// { kind: 'video', status: 'Offline' },
// ]
// const [liveStreams, otherStreams] = filterStreams(streams)
// expect(liveStreams).toHaveLength(1)
// expect(otherStreams).toHaveLength(2)
// })
// })
// describe('useYDoc', () => {
// it('should initialize with an empty Y.Doc', () => {
// const { result } = renderHook(() => useYDoc(['test']))
// expect(result.current[0]).toEqual({})
// })
// it('should update docValue when doc is updated', () => {
// const { result } = renderHook(() => useYDoc(['test']))
// act(() => {
// result.current[1].getMap('test').set('key', 'value')
// })
// expect(result.current[0]).toEqual({ test: { key: 'value' } })
// })
// })
// describe('useStreamwallConnection', () => {
// it('should initialize with default values', () => {
// const { result } = renderHook(() => useStreamwallConnection('ws://localhost:8080'))
// expect(result.current.isConnected).toBe(false)
// expect(result.current.config).toEqual({})
// expect(result.current.streams).toEqual([])
// expect(result.current.customStreams).toEqual([])
// expect(result.current.views).toEqual([])
// expect(result.current.stateIdxMap).toEqual(new Map())
// expect(result.current.delayState).toBeUndefined()
// expect(result.current.authState).toBeUndefined()
// })
// })

View File

@@ -1,3 +0,0 @@
import { main } from './control.js';
main();

View File

@@ -1,130 +0,0 @@
const path = require('path')
const CopyPlugin = require('copy-webpack-plugin')
const baseConfig = ({ babel }) => ({
mode: 'development',
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: babel,
},
},
{
test: /\.css$/i,
use: ['style-loader', 'css-loader'],
},
{
test: /\.ttf$/,
loader: 'file-loader',
options: {
name: '[name].[ext]',
},
},
{
test: /\.svg$/,
loader: '@svgr/webpack',
options: {
replaceAttrValues: {
'#333': 'currentColor',
'#555': '{props.color}',
},
},
},
],
},
resolve: {
extensions: ['.jsx', '.js'],
alias: {
react: 'preact/compat',
'react-dom': 'preact/compat',
},
},
})
const nodeConfig = {
...baseConfig({
babel: {
presets: [
[
'@babel/preset-env',
{
modules: 'commonjs',
targets: { node: true },
},
],
],
},
}),
target: 'electron-main',
entry: {
index: './src/node/index.js',
},
externals: {
consolidate: 'commonjs consolidate',
fsevents: 'commonjs fsevents',
},
}
const browserConfig = {
...baseConfig({
babel: {
presets: [['@babel/preset-env', { targets: { electron: '11' } }]],
},
}),
devtool: 'cheap-source-map',
target: 'electron-renderer',
entry: {
background: './src/browser/background.js',
overlay: './src/browser/overlay.js',
layerPreload: './src/browser/layerPreload.js',
mediaPreload: './src/browser/mediaPreload.js',
playHLS: './src/browser/playHLS.js',
},
plugins: [
new CopyPlugin({
patterns: [{ from: 'src/browser/*.html', to: '[name].html' }],
}),
],
}
const webConfig = {
...baseConfig({
babel: {
presets: [
[
'@babel/preset-env',
{
modules: 'commonjs',
targets: '> 0.25%, not dead',
},
],
],
},
}),
devtool: 'cheap-source-map',
target: 'web',
entry: {
control: './src/web/entrypoint.js',
},
output: {
path: path.resolve(__dirname, 'dist/web'),
},
plugins: [
new CopyPlugin({
patterns: [{ from: 'src/web/*.ejs', to: '[name].ejs' }],
}),
],
stats: {
colors: true,
modules: true,
reasons: true,
errorDetails: true,
warnings: true,
}
}
module.exports = [nodeConfig, browserConfig, webConfig]