Initial v2 overhaul
16
.eslintrc.json
Normal 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
@@ -1 +0,0 @@
|
||||
ko_fi: streamwall
|
||||
18
.github/dependabot.yml
vendored
@@ -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
@@ -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
|
||||
39
.github/workflows/test.yml
vendored
@@ -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
|
||||
9
.gitignore
vendored
@@ -1,8 +1 @@
|
||||
# Entire directories
|
||||
coverage
|
||||
dist
|
||||
node_modules
|
||||
reports
|
||||
|
||||
# Individual files
|
||||
junit.xml
|
||||
node_modules
|
||||
2
LICENSE
@@ -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:
|
||||
|
||||
|
||||
51
README.md
@@ -1,33 +1,22 @@
|
||||
# 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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
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+c**: Activate [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
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
module.exports = {};
|
||||
@@ -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 }]
|
||||
]
|
||||
}
|
||||
@@ -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'
|
||||
};
|
||||
19894
package-lock.json
generated
84
package.json
@@ -1,80 +1,12 @@
|
||||
{
|
||||
"name": "streamwall",
|
||||
"version": "0.0.1",
|
||||
"description": "View streams in a grid",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"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"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/streamwall",
|
||||
"packages/streamwall-shared",
|
||||
"packages/streamwall-control-ui"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.25.2",
|
||||
"@babel/plugin-proposal-decorators": "^7.24.7",
|
||||
"@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"
|
||||
]
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-organize-imports": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
24
packages/streamwall-control-ui/.gitignore
vendored
Normal 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?
|
||||
27
packages/streamwall-control-ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
23
packages/streamwall-control-ui/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
7
packages/streamwall-control-ui/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import preact from '@preact/preset-vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [preact()],
|
||||
})
|
||||
13
packages/streamwall-shared/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Color from 'color'
|
||||
|
||||
export function hashText(text, range) {
|
||||
export function hashText(text: string, range: number) {
|
||||
// DJBX33A-ish
|
||||
// based on https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/hueHash.js#L16-L45
|
||||
let val = 0
|
||||
@@ -22,7 +22,7 @@ export function hashText(text, range) {
|
||||
return (val + range) % range
|
||||
}
|
||||
|
||||
export function idColor(id) {
|
||||
export function idColor(id: string) {
|
||||
if (!id) {
|
||||
return Color('white')
|
||||
}
|
||||
@@ -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 visited = new Set()
|
||||
|
||||
function isPosContent(x, y, content) {
|
||||
function isPosContent(
|
||||
x: number,
|
||||
y: number,
|
||||
content: ViewContent | undefined,
|
||||
) {
|
||||
const checkIdx = width * y + x
|
||||
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 spaces = [idx]
|
||||
const content = viewContentMap.get(idx)
|
||||
const content = viewContentMap.get(String(idx))
|
||||
|
||||
let 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 x = 0; x < height; x++) {
|
||||
const idx = width * y + x
|
||||
if (visited.has(idx) || viewContentMap.get(idx) === undefined) {
|
||||
if (visited.has(idx) || viewContentMap.get(String(idx)) === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -60,15 +84,20 @@ export function boxesFromViewContentMap(width, height, viewContentMap) {
|
||||
return boxes
|
||||
}
|
||||
|
||||
export function idxToCoords(gridCount, idx) {
|
||||
export function idxToCoords(gridCount: number, idx: number) {
|
||||
const x = idx % gridCount
|
||||
const y = Math.floor(idx / gridCount)
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
export function idxInBox(gridCount, start, end, idx) {
|
||||
let { x: startX, y: startY } = idxToCoords(gridCount, start)
|
||||
let { x: endX, y: endY } = idxToCoords(gridCount, end)
|
||||
export function idxInBox(
|
||||
gridCount: number,
|
||||
start: number,
|
||||
end: number,
|
||||
idx: number,
|
||||
) {
|
||||
const { x: startX, y: startY } = idxToCoords(gridCount, start)
|
||||
const { x: endX, y: endY } = idxToCoords(gridCount, end)
|
||||
const { x, y } = idxToCoords(gridCount, idx)
|
||||
const lowX = Math.min(startX, endX)
|
||||
const highX = Math.max(startX, endX)
|
||||
4
packages/streamwall-shared/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './colors'
|
||||
export * from './geometry'
|
||||
export * from './roles'
|
||||
export * from './types'
|
||||
43
packages/streamwall-shared/src/roles.ts
Normal 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
|
||||
}
|
||||
102
packages/streamwall-shared/src/types.ts
Normal 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 }
|
||||
31
packages/streamwall-shared/tsconfig.json
Normal 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
@@ -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/
|
||||
76
packages/streamwall/forge.config.ts
Normal 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
@@ -0,0 +1 @@
|
||||
/// <reference types="@electron-forge/plugin-vite/forge-vite-env" />
|
||||
68
packages/streamwall/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
68
packages/streamwall/src/main/ControlWindow.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
364
packages/streamwall/src/main/StreamWindow.ts
Normal 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),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,22 @@
|
||||
import assert from 'assert'
|
||||
import EventEmitter from 'events'
|
||||
import ReconnectingWebSocket from 'reconnecting-websocket'
|
||||
import { StreamDelayStatus } from 'streamwall-shared'
|
||||
import * as url from 'url'
|
||||
import WebSocket from 'ws'
|
||||
import ReconnectingWebSocket from 'reconnecting-websocket'
|
||||
|
||||
export interface StreamdelayClientOptions {
|
||||
endpoint: string
|
||||
key: string
|
||||
}
|
||||
|
||||
export default class StreamdelayClient extends EventEmitter {
|
||||
constructor({ endpoint, key }) {
|
||||
endpoint: string
|
||||
key: string
|
||||
ws: ReconnectingWebSocket | null
|
||||
status: StreamDelayStatus | null
|
||||
|
||||
constructor({ endpoint, key }: StreamdelayClientOptions) {
|
||||
super()
|
||||
this.endpoint = endpoint
|
||||
this.key = key
|
||||
@@ -39,7 +51,7 @@ export default class StreamdelayClient extends EventEmitter {
|
||||
}
|
||||
|
||||
emitState() {
|
||||
const isConnected = this.ws.readyState === WebSocket.OPEN
|
||||
const isConnected = this.ws?.readyState === WebSocket.OPEN
|
||||
if (isConnected && !this.status) {
|
||||
// Wait until we've received the first status message
|
||||
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 }))
|
||||
}
|
||||
|
||||
setStreamRunning(isStreamRunning) {
|
||||
setStreamRunning(isStreamRunning: boolean) {
|
||||
assert(this.ws != null, 'Must be connected')
|
||||
this.ws.send(JSON.stringify({ isStreamRunning }))
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,25 @@
|
||||
import TOML from '@iarna/toml'
|
||||
import { Repeater } from '@repeaterjs/repeater'
|
||||
import { watch } from 'chokidar'
|
||||
import { EventEmitter, once } from 'events'
|
||||
import { promises as fsPromises } from 'fs'
|
||||
import { promisify } from 'util'
|
||||
import { Repeater } from '@repeaterjs/repeater'
|
||||
import TOML from '@iarna/toml'
|
||||
import { isArray } from 'lodash-es'
|
||||
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)
|
||||
|
||||
export async function* pollDataURL(url, intervalSecs) {
|
||||
type DataSource = AsyncGenerator<StreamData[]>
|
||||
|
||||
export async function* pollDataURL(url: string, intervalSecs: number) {
|
||||
const refreshInterval = intervalSecs * 1000
|
||||
let lastData = []
|
||||
while (true) {
|
||||
let data = []
|
||||
let data: StreamData[] = []
|
||||
try {
|
||||
const resp = await fetch(url)
|
||||
data = await resp.json()
|
||||
data = (await resp.json()) as StreamData[]
|
||||
} catch (err) {
|
||||
console.warn('error loading stream data', err)
|
||||
}
|
||||
@@ -32,24 +36,27 @@ export async function* pollDataURL(url, intervalSecs) {
|
||||
}
|
||||
}
|
||||
|
||||
export async function* watchDataFile(path) {
|
||||
const watcher = chokidar.watch(path)
|
||||
export async function* watchDataFile(path: string): DataSource {
|
||||
const watcher = watch(path)
|
||||
while (true) {
|
||||
let data
|
||||
try {
|
||||
const text = await fsPromises.readFile(path)
|
||||
data = TOML.parse(text)
|
||||
data = TOML.parse(text.toString())
|
||||
} catch (err) {
|
||||
console.warn('error reading data file', err)
|
||||
}
|
||||
if (data) {
|
||||
yield data.streams || []
|
||||
if (data && isArray(data.streams)) {
|
||||
// TODO: type validate with Zod
|
||||
yield data.streams as unknown as StreamList
|
||||
} else {
|
||||
yield []
|
||||
}
|
||||
await once(watcher, 'change')
|
||||
}
|
||||
}
|
||||
|
||||
export async function* markDataSource(dataSource, name) {
|
||||
export async function* markDataSource(dataSource: DataSource, name: string) {
|
||||
for await (const streamList of dataSource) {
|
||||
for (const s of streamList) {
|
||||
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)) {
|
||||
const dataByURL = new Map()
|
||||
const dataByURL = new Map<string, StreamData>()
|
||||
for (const list of streamLists) {
|
||||
for (const data of list) {
|
||||
const existing = dataByURL.get(data.link)
|
||||
dataByURL.set(data.link, { ...existing, ...data })
|
||||
}
|
||||
}
|
||||
const streams = [...dataByURL.values()]
|
||||
const streams: StreamList = [...dataByURL.values()]
|
||||
// Retain the index to speed up local lookups
|
||||
streams.byURL = dataByURL
|
||||
yield streams
|
||||
@@ -75,24 +82,23 @@ export async function* combineDataSources(dataSources) {
|
||||
}
|
||||
|
||||
export class LocalStreamData extends EventEmitter {
|
||||
dataByURL: Map<string, Partial<StreamData>>
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.dataByURL = new Map()
|
||||
}
|
||||
|
||||
update(url, data) {
|
||||
if (!data.link) {
|
||||
data.link = url
|
||||
}
|
||||
update(url: string, data: Partial<StreamData>) {
|
||||
const existing = this.dataByURL.get(url)
|
||||
this.dataByURL.set(data.link, { ...existing, ...data })
|
||||
if (url !== data.link) {
|
||||
this.dataByURL.set(data.link ?? url, { ...existing, ...data, link: url })
|
||||
if (data.link != null && url !== data.link) {
|
||||
this.dataByURL.delete(url)
|
||||
}
|
||||
this._emitUpdate()
|
||||
}
|
||||
|
||||
delete(url) {
|
||||
delete(url: string) {
|
||||
this.dataByURL.delete(url)
|
||||
this._emitUpdate()
|
||||
}
|
||||
@@ -101,7 +107,7 @@ export class LocalStreamData extends EventEmitter {
|
||||
this.emit('update', [...this.dataByURL.values()])
|
||||
}
|
||||
|
||||
gen() {
|
||||
gen(): AsyncGenerator<StreamData[]> {
|
||||
return new Repeater(async (push, stop) => {
|
||||
await push([])
|
||||
this.on('update', push)
|
||||
@@ -112,17 +118,21 @@ export class LocalStreamData extends EventEmitter {
|
||||
}
|
||||
|
||||
export class StreamIDGenerator {
|
||||
idMap: Map<string, string>
|
||||
idSet: Set<string>
|
||||
|
||||
constructor() {
|
||||
this.idMap = new Map()
|
||||
this.idSet = new Set()
|
||||
}
|
||||
|
||||
process(streams) {
|
||||
process(streams: StreamData[]) {
|
||||
const { idMap, idSet } = this
|
||||
|
||||
for (const stream of streams) {
|
||||
const { link, source, label } = stream
|
||||
if (!idMap.has(link)) {
|
||||
let streamId = idMap.get(link)
|
||||
if (streamId == null) {
|
||||
let counter = 0
|
||||
let newId
|
||||
const idBase = source || label || link
|
||||
@@ -141,11 +151,12 @@ export class StreamIDGenerator {
|
||||
counter++
|
||||
} while (idSet.has(newId))
|
||||
|
||||
idMap.set(link, newId)
|
||||
idSet.add(newId)
|
||||
streamId = newId
|
||||
idMap.set(link, streamId)
|
||||
idSet.add(streamId)
|
||||
}
|
||||
|
||||
stream._id = idMap.get(link)
|
||||
stream._id = streamId
|
||||
}
|
||||
return streams
|
||||
}
|
||||
@@ -1,149 +1,128 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import yargs from 'yargs'
|
||||
import TOML from '@iarna/toml'
|
||||
import * as Y from 'yjs'
|
||||
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 ControlWindow from './ControlWindow'
|
||||
import {
|
||||
pollDataURL,
|
||||
watchDataFile,
|
||||
LocalStreamData,
|
||||
StreamIDGenerator,
|
||||
markDataSource,
|
||||
combineDataSources,
|
||||
markDataSource,
|
||||
pollDataURL,
|
||||
watchDataFile,
|
||||
} 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 initWebServer from './server'
|
||||
import StreamWindow from './StreamWindow'
|
||||
|
||||
const SENTRY_DSN =
|
||||
'https://e630a21dcf854d1a9eb2a7a8584cbd0b@o459879.ingest.sentry.io/5459505'
|
||||
|
||||
function parseArgs() {
|
||||
return yargs
|
||||
.config('config', (configPath) => {
|
||||
return TOML.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||
})
|
||||
.group(['grid.count'], 'Grid dimensions')
|
||||
.option('grid.count', {
|
||||
number: true,
|
||||
default: 3,
|
||||
})
|
||||
.group(
|
||||
[
|
||||
'window.width',
|
||||
'window.height',
|
||||
'window.x',
|
||||
'window.y',
|
||||
'window.frameless',
|
||||
'window.background-color',
|
||||
'window.active-color',
|
||||
],
|
||||
'Window settings',
|
||||
)
|
||||
.option('window.x', {
|
||||
number: true,
|
||||
})
|
||||
.option('window.y', {
|
||||
number: true,
|
||||
})
|
||||
.option('window.width', {
|
||||
number: true,
|
||||
default: 1920,
|
||||
})
|
||||
.option('window.height', {
|
||||
number: true,
|
||||
default: 1080,
|
||||
})
|
||||
.option('window.frameless', {
|
||||
boolean: true,
|
||||
default: false,
|
||||
})
|
||||
.option('window.background-color', {
|
||||
describe: 'Background color of wall (useful for chroma-keying)',
|
||||
default: '#000',
|
||||
})
|
||||
.option('window.active-color', {
|
||||
describe: 'Active (highlight) color of wall',
|
||||
default: '#fff',
|
||||
})
|
||||
.group(['data.interval', 'data.json-url', 'data.toml-file'], 'Datasources')
|
||||
.option('data.interval', {
|
||||
describe: 'Interval (in seconds) for refreshing polled data sources',
|
||||
number: true,
|
||||
default: 30,
|
||||
})
|
||||
.option('data.json-url', {
|
||||
describe: 'Fetch streams from the specified URL(s)',
|
||||
array: true,
|
||||
default: [],
|
||||
})
|
||||
.option('data.toml-file', {
|
||||
describe: 'Fetch streams from the specified file(s)',
|
||||
normalize: true,
|
||||
array: true,
|
||||
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,
|
||||
})
|
||||
export interface StreamwallConfig {
|
||||
help: boolean
|
||||
grid: {
|
||||
count: number
|
||||
}
|
||||
window: {
|
||||
x?: number
|
||||
y?: number
|
||||
width: number
|
||||
height: number
|
||||
frameless: boolean
|
||||
'background-color': string
|
||||
'active-color': string
|
||||
}
|
||||
data: {
|
||||
interval: number
|
||||
'json-url': string[]
|
||||
'toml-file': string[]
|
||||
}
|
||||
streamdelay: {
|
||||
endpoint: string
|
||||
key: string | null
|
||||
}
|
||||
telemetry: {
|
||||
sentry: boolean
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(): StreamwallConfig {
|
||||
return (
|
||||
yargs()
|
||||
.config('config', (configPath) => {
|
||||
return TOML.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||
})
|
||||
.group(['grid.count'], 'Grid dimensions')
|
||||
.option('grid.count', {
|
||||
number: true,
|
||||
default: 3,
|
||||
})
|
||||
.group(
|
||||
[
|
||||
'window.width',
|
||||
'window.height',
|
||||
'window.x',
|
||||
'window.y',
|
||||
'window.frameless',
|
||||
'window.background-color',
|
||||
'window.active-color',
|
||||
],
|
||||
'Window settings',
|
||||
)
|
||||
.option('window.x', {
|
||||
number: true,
|
||||
})
|
||||
.option('window.y', {
|
||||
number: true,
|
||||
})
|
||||
.option('window.width', {
|
||||
number: true,
|
||||
default: 1920,
|
||||
})
|
||||
.option('window.height', {
|
||||
number: true,
|
||||
default: 1080,
|
||||
})
|
||||
.option('window.frameless', {
|
||||
boolean: true,
|
||||
default: false,
|
||||
})
|
||||
.option('window.background-color', {
|
||||
describe: 'Background color of wall (useful for chroma-keying)',
|
||||
default: '#000',
|
||||
})
|
||||
.option('window.active-color', {
|
||||
describe: 'Active (highlight) color of wall',
|
||||
default: '#fff',
|
||||
})
|
||||
.group(
|
||||
['data.interval', 'data.json-url', 'data.toml-file'],
|
||||
'Datasources',
|
||||
)
|
||||
.option('data.interval', {
|
||||
describe: 'Interval (in seconds) for refreshing polled data sources',
|
||||
number: true,
|
||||
default: 30,
|
||||
})
|
||||
.option('data.json-url', {
|
||||
describe: 'Fetch streams from the specified URL(s)',
|
||||
array: true,
|
||||
string: true,
|
||||
default: [],
|
||||
})
|
||||
.option('data.toml-file', {
|
||||
describe: 'Fetch streams from the specified file(s)',
|
||||
normalize: true,
|
||||
array: true,
|
||||
default: [],
|
||||
})
|
||||
/*
|
||||
.group(
|
||||
[
|
||||
'control.username',
|
||||
@@ -169,6 +148,7 @@ function parseArgs() {
|
||||
.option('control.address', {
|
||||
describe: 'Enable control webserver and specify the URL',
|
||||
implies: ['control.username', 'control.password'],
|
||||
string: true,
|
||||
})
|
||||
.option('control.hostname', {
|
||||
describe: 'Override hostname the control server listens on',
|
||||
@@ -192,25 +172,29 @@ function parseArgs() {
|
||||
.option('cert.email', {
|
||||
describe: 'Email for owner of SSL certificate',
|
||||
})
|
||||
.group(['streamdelay.endpoint', 'streamdelay.key'], 'Streamdelay')
|
||||
.option('streamdelay.endpoint', {
|
||||
describe: 'URL of Streamdelay endpoint',
|
||||
default: 'http://localhost:8404',
|
||||
})
|
||||
.option('streamdelay.key', {
|
||||
describe: 'Streamdelay API key',
|
||||
default: null,
|
||||
})
|
||||
.group(['telemetry.sentry'], 'Telemetry')
|
||||
.option('telemetry.sentry', {
|
||||
describe: 'Enable error reporting to Sentry',
|
||||
boolean: true,
|
||||
default: true,
|
||||
})
|
||||
.help().argv
|
||||
*/
|
||||
.group(['streamdelay.endpoint', 'streamdelay.key'], 'Streamdelay')
|
||||
.option('streamdelay.endpoint', {
|
||||
describe: 'URL of Streamdelay endpoint',
|
||||
default: 'http://localhost:8404',
|
||||
})
|
||||
.option('streamdelay.key', {
|
||||
describe: 'Streamdelay API key',
|
||||
default: null,
|
||||
})
|
||||
.group(['telemetry.sentry'], 'Telemetry')
|
||||
.option('telemetry.sentry', {
|
||||
describe: 'Enable error reporting to Sentry',
|
||||
boolean: true,
|
||||
default: true,
|
||||
})
|
||||
.help()
|
||||
// https://github.com/yargs/yargs/issues/2137
|
||||
.parseSync() as unknown as StreamwallConfig
|
||||
)
|
||||
}
|
||||
|
||||
async function main(argv) {
|
||||
async function main(argv: ReturnType<typeof parseArgs>) {
|
||||
// Reject all permission requests from web content.
|
||||
session
|
||||
.fromPartition('persist:session')
|
||||
@@ -218,67 +202,50 @@ async function main(argv) {
|
||||
callback(false)
|
||||
})
|
||||
|
||||
console.debug('Loading persistence data...')
|
||||
const persistData = await persistence.load()
|
||||
|
||||
console.debug('Creating StreamWindow...')
|
||||
const idGen = new StreamIDGenerator()
|
||||
const localStreamData = new LocalStreamData()
|
||||
const overlayStreamData = new LocalStreamData()
|
||||
|
||||
const streamWindow = new StreamWindow({
|
||||
const streamWindowConfig = {
|
||||
gridCount: argv.grid.count,
|
||||
width: argv.window.width,
|
||||
height: argv.window.height,
|
||||
x: argv.window.x,
|
||||
y: argv.window.y,
|
||||
frameless: argv.window.frameless,
|
||||
activeColor: argv.window['active-color'],
|
||||
backgroundColor: argv.window['background-color'],
|
||||
})
|
||||
streamWindow.init()
|
||||
}
|
||||
const streamWindow = new StreamWindow(streamWindowConfig)
|
||||
const controlWindow = new ControlWindow()
|
||||
|
||||
console.debug('Creating Auth...')
|
||||
const auth = new Auth({
|
||||
adminUsername: argv.control.username,
|
||||
adminPassword: argv.control.password,
|
||||
persistData: persistData.auth,
|
||||
logEnabled: true,
|
||||
})
|
||||
|
||||
let browseWindow = null
|
||||
let twitchBot = null
|
||||
let streamdelayClient = null
|
||||
let browseWindow: BrowserWindow | null = null
|
||||
let streamdelayClient: StreamdelayClient | null = null
|
||||
|
||||
console.debug('Creating initial state...')
|
||||
let clientState = new StateWrapper({
|
||||
config: {
|
||||
width: argv.window.width,
|
||||
height: argv.window.height,
|
||||
gridCount: argv.grid.count,
|
||||
activeColor: argv.window['active-color'],
|
||||
},
|
||||
auth: auth.getState(),
|
||||
let clientState: StreamwallState = {
|
||||
config: streamWindowConfig,
|
||||
streams: [],
|
||||
views: [],
|
||||
streamdelay: null,
|
||||
})
|
||||
}
|
||||
|
||||
const stateDoc = new Y.Doc()
|
||||
const viewsState = stateDoc.getMap('views')
|
||||
const viewsState = stateDoc.getMap<Y.Map<string | undefined>>('views')
|
||||
stateDoc.transact(() => {
|
||||
for (let i = 0; i < argv.grid.count ** 2; i++) {
|
||||
const data = new Y.Map()
|
||||
data.set('streamId', '')
|
||||
viewsState.set(i, data)
|
||||
const data = new Y.Map<string | undefined>()
|
||||
data.set('streamId', undefined)
|
||||
viewsState.set(String(i), data)
|
||||
}
|
||||
})
|
||||
viewsState.observeDeep(() => {
|
||||
try {
|
||||
const viewContentMap = new Map()
|
||||
for (const [key, viewData] of viewsState) {
|
||||
const stream = clientState.info.streams.find(
|
||||
(s) => s._id === viewData.get('streamId'),
|
||||
)
|
||||
const streamId = viewData.get('streamId')
|
||||
const stream = clientState.streams.find((s) => s._id === streamId)
|
||||
if (!stream) {
|
||||
continue
|
||||
}
|
||||
@@ -287,19 +254,23 @@ async function main(argv) {
|
||||
kind: stream.kind || 'video',
|
||||
})
|
||||
}
|
||||
streamWindow.setViews(viewContentMap, clientState.info.streams)
|
||||
streamWindow.setViews(viewContentMap, clientState.streams)
|
||||
} catch (err) {
|
||||
console.error('Error updating views', err)
|
||||
}
|
||||
})
|
||||
|
||||
const onMessage = async (msg, respond) => {
|
||||
const onCommand = async (msg: ControlCommand) => {
|
||||
console.debug('Received message:', msg)
|
||||
if (msg.type === 'set-listening-view') {
|
||||
console.debug('Setting listening view:', msg.viewIdx)
|
||||
streamWindow.setListeningView(msg.viewIdx)
|
||||
} else if (msg.type === 'set-view-background-listening') {
|
||||
console.debug('Setting view background listening:', msg.viewIdx, msg.listening)
|
||||
console.debug(
|
||||
'Setting view background listening:',
|
||||
msg.viewIdx,
|
||||
msg.listening,
|
||||
)
|
||||
streamWindow.setViewBackgroundListening(msg.viewIdx, msg.listening)
|
||||
} else if (msg.type === 'set-view-blurred') {
|
||||
console.debug('Setting view blurred:', msg.viewIdx, msg.blurred)
|
||||
@@ -347,7 +318,7 @@ async function main(argv) {
|
||||
console.error('Invalid URL:', msg.url)
|
||||
console.error('Error:', error)
|
||||
}
|
||||
} else if (msg.type === 'dev-tools') {
|
||||
} else if (msg.type === 'dev-tools') {
|
||||
console.debug('Opening DevTools for view:', msg.viewIdx)
|
||||
streamWindow.openDevTools(msg.viewIdx, browseWindow.webContents)
|
||||
}
|
||||
@@ -357,7 +328,8 @@ async function main(argv) {
|
||||
} else if (msg.type === 'set-stream-running' && streamdelayClient) {
|
||||
console.debug('Setting stream running:', 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)
|
||||
const { secret } = await auth.createToken({
|
||||
kind: 'invite',
|
||||
@@ -368,17 +340,61 @@ async function main(argv) {
|
||||
} else if (msg.type === 'delete-token') {
|
||||
console.debug('Deleting token:', msg.tokenId)
|
||||
auth.deleteToken(msg.tokenId)
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
function updateState(newState) {
|
||||
clientState.update(newState)
|
||||
streamWindow.onState(clientState.info)
|
||||
if (twitchBot) {
|
||||
twitchBot.onState(clientState.info)
|
||||
}
|
||||
function updateState(newState: Partial<StreamwallState>) {
|
||||
clientState = { ...clientState, ...newState }
|
||||
streamWindow.onState(clientState)
|
||||
controlWindow.onState(clientState)
|
||||
}
|
||||
|
||||
// Wire up IPC:
|
||||
|
||||
// StreamWindow view updates -> main
|
||||
streamWindow.on('state', (viewStates) => {
|
||||
updateState({ views: viewStates })
|
||||
})
|
||||
|
||||
// StreamWindow <- main init state
|
||||
streamWindow.on('load', () => {
|
||||
streamWindow.onState(clientState)
|
||||
})
|
||||
|
||||
// Control <- main collab updates
|
||||
stateDoc.on('update', (update) => {
|
||||
controlWindow.onYDocUpdate(update)
|
||||
})
|
||||
|
||||
// Control <- main init state
|
||||
controlWindow.on('load', () => {
|
||||
controlWindow.onState(clientState)
|
||||
controlWindow.onYDocUpdate(Y.encodeStateAsUpdate(stateDoc))
|
||||
})
|
||||
|
||||
// Control -> main
|
||||
controlWindow.on('ydoc', (update) => Y.applyUpdate(stateDoc, update))
|
||||
controlWindow.on('command', (command) => onCommand(command))
|
||||
|
||||
// TODO: Hide on macOS, allow reopening from dock
|
||||
streamWindow.on('close', () => {
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
if (argv.streamdelay.key) {
|
||||
console.debug('Setting up Streamdelay client...')
|
||||
streamdelayClient = new StreamdelayClient({
|
||||
endpoint: argv.streamdelay.endpoint,
|
||||
key: argv.streamdelay.key,
|
||||
})
|
||||
streamdelayClient.on('state', (state) => {
|
||||
updateState({ streamdelay: state })
|
||||
})
|
||||
streamdelayClient.connect()
|
||||
}
|
||||
|
||||
/*
|
||||
if (argv.control.address) {
|
||||
console.debug('Initializing web server...')
|
||||
const webDistPath = path.join(app.getAppPath(), 'web')
|
||||
@@ -400,40 +416,7 @@ async function main(argv) {
|
||||
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 = [
|
||||
...argv.data['json-url'].map((url) => {
|
||||
@@ -467,22 +450,28 @@ function init() {
|
||||
Sentry.init({ dsn: SENTRY_DSN })
|
||||
}
|
||||
|
||||
updateElectronApp()
|
||||
|
||||
console.debug('Setting up Electron...')
|
||||
app.commandLine.appendSwitch('high-dpi-support', 1)
|
||||
app.commandLine.appendSwitch('force-device-scale-factor', 1)
|
||||
app.commandLine.appendSwitch('high-dpi-support', '1')
|
||||
app.commandLine.appendSwitch('force-device-scale-factor', '1')
|
||||
|
||||
console.debug('Enabling Electron sandbox...')
|
||||
app.enableSandbox()
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
.then(() => main(argv))
|
||||
.catch((err) => {
|
||||
console.trace(err.toString())
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
console.debug('Starting Streamwall...')
|
||||
init()
|
||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||
if (started) {
|
||||
app.quit()
|
||||
}
|
||||
|
||||
console.debug('Starting Streamwall...')
|
||||
init()
|
||||
24
packages/streamwall/src/main/loadHTML.ts
Normal 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
330
packages/streamwall/src/main/viewStateMachine.ts
Normal 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
|
||||
30
packages/streamwall/src/preload/controlPreload.ts
Normal 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)
|
||||
19
packages/streamwall/src/preload/layerPreload.ts
Normal 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)
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ipcRenderer, webFrame } from 'electron'
|
||||
import throttle from 'lodash/throttle'
|
||||
import { ContentDisplayOptions } from 'streamwall-shared'
|
||||
|
||||
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) =>
|
||||
document.addEventListener('DOMContentLoaded', resolve, { once: true }),
|
||||
)
|
||||
|
||||
class RotationController {
|
||||
constructor(video) {
|
||||
video: HTMLVideoElement
|
||||
siteRotation: number
|
||||
customRotation: number
|
||||
|
||||
constructor(video: HTMLVideoElement) {
|
||||
this.video = video
|
||||
this.siteRotation = 0
|
||||
this.customRotation = 0
|
||||
@@ -104,7 +110,7 @@ async function lockdownMediaTags() {
|
||||
if (el.__sw) {
|
||||
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 })
|
||||
// Prevent Facebook from pausing the video after page load.
|
||||
Object.defineProperty(el, 'pause', { writable: false, value: () => {} })
|
||||
@@ -117,9 +123,10 @@ async function lockdownMediaTags() {
|
||||
observer.observe(document.body, { subtree: true, childList: true })
|
||||
}
|
||||
|
||||
function waitForQuery(query) {
|
||||
async function waitForQuery(query: string): Promise<Element> {
|
||||
console.log(`waiting for '${query}'...`)
|
||||
return new Promise(async (resolve) => {
|
||||
await pageReady
|
||||
return new Promise((resolve) => {
|
||||
const scan = throttle(() => {
|
||||
const el = document.querySelector(query)
|
||||
if (el) {
|
||||
@@ -129,79 +136,36 @@ function waitForQuery(query) {
|
||||
}
|
||||
}, SCAN_THROTTLE)
|
||||
|
||||
await pageReady
|
||||
const observer = new MutationObserver(scan)
|
||||
observer.observe(document.body, { subtree: true, childList: true })
|
||||
scan()
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForVideo(kind) {
|
||||
async function waitForVideo(kind: 'video' | 'audio'): Promise<{
|
||||
video?: HTMLMediaElement
|
||||
iframe?: HTMLIFrameElement
|
||||
}> {
|
||||
lockdownMediaTags()
|
||||
|
||||
let video = await Promise.race([waitForQuery(kind), sleep(10 * 1000)])
|
||||
if (video) {
|
||||
let video: Element | null | void = await Promise.race([
|
||||
waitForQuery(kind),
|
||||
sleep(10 * 1000),
|
||||
])
|
||||
if (video instanceof HTMLMediaElement) {
|
||||
return { video }
|
||||
}
|
||||
|
||||
let iframe
|
||||
for (iframe of document.querySelectorAll('iframe')) {
|
||||
video = iframe.contentDocument?.querySelector?.(kind)
|
||||
if (video) {
|
||||
if (video instanceof HTMLVideoElement) {
|
||||
return { video, iframe }
|
||||
}
|
||||
}
|
||||
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 = {
|
||||
isMatch() {
|
||||
return location.host === 'www.instagram.com'
|
||||
@@ -213,6 +177,7 @@ const igHacks = {
|
||||
sleep(1000),
|
||||
])
|
||||
if (
|
||||
playButton instanceof HTMLButtonElement &&
|
||||
playButton.tagName === 'BUTTON' &&
|
||||
playButton.textContent === 'Tap to play'
|
||||
) {
|
||||
@@ -221,10 +186,7 @@ const igHacks = {
|
||||
},
|
||||
}
|
||||
|
||||
async function findVideo(kind) {
|
||||
if (periscopeHacks.isMatch()) {
|
||||
await periscopeHacks.onLoad()
|
||||
}
|
||||
async function findVideo(kind: 'video' | 'audio') {
|
||||
if (igHacks.isMatch()) {
|
||||
await igHacks.onLoad()
|
||||
}
|
||||
@@ -233,7 +195,7 @@ async function findVideo(kind) {
|
||||
if (!video) {
|
||||
throw new Error('could not find video')
|
||||
}
|
||||
if (iframe) {
|
||||
if (iframe && iframe.contentDocument) {
|
||||
// TODO: verify iframe still works
|
||||
const style = iframe.contentDocument.createElement('style')
|
||||
style.innerHTML = VIDEO_OVERRIDE_STYLE
|
||||
@@ -251,7 +213,7 @@ async function findVideo(kind) {
|
||||
|
||||
video.play()
|
||||
|
||||
if (!video.videoWidth) {
|
||||
if (video instanceof HTMLVideoElement && !video.videoWidth) {
|
||||
console.log(`video isn't playing yet. waiting for it to start...`)
|
||||
const videoReady = new Promise((resolve) =>
|
||||
video.addEventListener('playing', resolve, { once: true }),
|
||||
@@ -280,15 +242,12 @@ async function main() {
|
||||
pageReady,
|
||||
])
|
||||
|
||||
let rotationController
|
||||
let rotationController: RotationController | undefined
|
||||
if (content.kind === 'video' || content.kind === 'audio') {
|
||||
webFrame.insertCSS(VIDEO_OVERRIDE_STYLE, { cssOrigin: 'user' })
|
||||
const { info, video } = await findVideo(content.kind)
|
||||
if (content.kind === 'video') {
|
||||
if (content.kind === 'video' && video instanceof HTMLVideoElement) {
|
||||
rotationController = new RotationController(video)
|
||||
if (periscopeHacks.isMatch()) {
|
||||
periscopeHacks.afterPlay(rotationController)
|
||||
}
|
||||
}
|
||||
ipcRenderer.send('view-info', { info })
|
||||
} else if (content.kind === 'web') {
|
||||
@@ -297,7 +256,7 @@ async function main() {
|
||||
|
||||
ipcRenderer.send('view-loaded')
|
||||
|
||||
function updateOptions(options) {
|
||||
function updateOptions(options: ContentDisplayOptions) {
|
||||
if (rotationController) {
|
||||
rotationController.setCustom(options.rotation)
|
||||
}
|
||||
@@ -306,6 +265,6 @@ async function main() {
|
||||
updateOptions(initialOptions)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
ipcRenderer.send('view-error', { err })
|
||||
main().catch((error) => {
|
||||
ipcRenderer.send('view-error', { error })
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -9,6 +9,6 @@
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<script src="background.js" type="module"></script>
|
||||
<script src="background.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
55
packages/streamwall/src/renderer/background.tsx
Normal 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)
|
||||
14
packages/streamwall/src/renderer/control.html
Normal 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>
|
||||
98
packages/streamwall/src/renderer/control.tsx
Normal 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)
|
||||
3
packages/streamwall/src/renderer/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
font-family: 'Noto Sans';
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -9,6 +9,6 @@
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<script src="overlay.js" type="module"></script>
|
||||
<script src="overlay.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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 { 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'
|
||||
import FacebookIcon from '../static/facebook.svg'
|
||||
import PeriscopeIcon from '../static/periscope.svg'
|
||||
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'
|
||||
declare global {
|
||||
interface Window {
|
||||
streamwallLayer: StreamwallLayerGlobal
|
||||
}
|
||||
}
|
||||
|
||||
function Overlay({ config, views, streams }) {
|
||||
function Overlay({
|
||||
config,
|
||||
views,
|
||||
streams,
|
||||
}: Pick<StreamwallState, 'config' | 'views' | 'streams'>) {
|
||||
const { width, height, activeColor } = config
|
||||
const activeViews = views
|
||||
.map(({ state, context }) => State.from(state, context))
|
||||
.filter((s) => s.matches('displaying') && !s.matches('displaying.error'))
|
||||
const activeViews = views.filter(
|
||||
({ state }) =>
|
||||
matchesState('displaying', state) &&
|
||||
!matchesState('displaying.error', state),
|
||||
)
|
||||
const overlays = streams.filter((s) => s.kind === 'overlay')
|
||||
return (
|
||||
<div>
|
||||
{activeViews.map((viewState) => {
|
||||
const { content, pos } = viewState.context
|
||||
{activeViews.map(({ state, context }) => {
|
||||
const { content, pos } = context
|
||||
if (!content) {
|
||||
return
|
||||
}
|
||||
|
||||
const data = streams.find((d) => content.url === d.link)
|
||||
const isListening = viewState.matches(
|
||||
const isListening = matchesState(
|
||||
'displaying.running.audio.listening',
|
||||
state,
|
||||
)
|
||||
const isBackgroundListening = viewState.matches(
|
||||
const isBackgroundListening = matchesState(
|
||||
'displaying.running.audio.background',
|
||||
state,
|
||||
)
|
||||
const isBlurred = viewState.matches('displaying.running.video.blurred')
|
||||
const isLoading = viewState.matches('displaying.loading')
|
||||
const isBlurred = matchesState(
|
||||
'displaying.running.video.blurred',
|
||||
state,
|
||||
)
|
||||
const isLoading = matchesState('displaying.loading', state)
|
||||
const hasTitle = data && (data.label || data.source)
|
||||
const position = data?.labelPosition ?? 'top-left'
|
||||
return (
|
||||
@@ -53,16 +77,8 @@ function Overlay({ config, views, streams }) {
|
||||
isListening={isListening}
|
||||
>
|
||||
<StreamIcon url={content.url} />
|
||||
<span>
|
||||
{data.hasOwnProperty('label') ? (
|
||||
data.label
|
||||
) : (
|
||||
<>
|
||||
{data.source} – {data.city} {data.state}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{(isListening || isBackgroundListening) && <SoundIcon />}
|
||||
<span>{data.label}</span>
|
||||
{(isListening || isBackgroundListening) && <FaVolumeUp />}
|
||||
</StreamTitle>
|
||||
)}
|
||||
{isLoading && <LoadingSpinner />}
|
||||
@@ -82,33 +98,27 @@ function Overlay({ config, views, streams }) {
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [state, setState] = useState({
|
||||
config: {},
|
||||
views: [],
|
||||
streams: [],
|
||||
customStreams: [],
|
||||
})
|
||||
const [state, setState] = useState<StreamwallState | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
streamwall.onState(setState)
|
||||
const unsubscribe = window.streamwallLayer.onState(setState)
|
||||
window.streamwallLayer.load()
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
useHotkeys('ctrl+shift+i', () => {
|
||||
streamwall.openDevTools()
|
||||
window.streamwallLayer.openDevTools()
|
||||
})
|
||||
|
||||
const { config, views, streams, customStreams } = state
|
||||
return (
|
||||
<Overlay
|
||||
config={config}
|
||||
views={views}
|
||||
streams={streams}
|
||||
customStreams={customStreams}
|
||||
/>
|
||||
)
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
|
||||
const { config, views, streams } = state
|
||||
return <Overlay config={config} views={views} streams={streams} />
|
||||
}
|
||||
|
||||
function StreamIcon({ url, ...props }) {
|
||||
function StreamIcon({ url }: { url: string }) {
|
||||
let parsedURL
|
||||
try {
|
||||
parsedURL = new URL(url)
|
||||
@@ -119,26 +129,20 @@ function StreamIcon({ url, ...props }) {
|
||||
let { host } = parsedURL
|
||||
host = host.replace(/^www\./, '')
|
||||
if (host === 'youtube.com' || host === 'youtu.be') {
|
||||
return <YouTubeIcon {...props} />
|
||||
return <FaYoutube />
|
||||
} else if (host === 'facebook.com' || host === 'm.facebook.com') {
|
||||
return <FacebookIcon {...props} />
|
||||
return <FaFacebook />
|
||||
} else if (host === 'twitch.tv') {
|
||||
return <TwitchIcon {...props} />
|
||||
} else if (
|
||||
host === 'periscope.tv' ||
|
||||
host === 'pscp.tv' ||
|
||||
host === 'twitter.com'
|
||||
) {
|
||||
return <PeriscopeIcon {...props} />
|
||||
return <FaTwitch />
|
||||
} else if (host === 'instagram.com') {
|
||||
return <InstagramIcon {...props} />
|
||||
return <FaInstagram />
|
||||
} else if (host === 'tiktok.com') {
|
||||
return <TikTokIcon {...props} />
|
||||
return <FaTiktok />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const SpaceBorder = styled.div.attrs((props) => ({
|
||||
const SpaceBorder = styled.div.attrs(() => ({
|
||||
borderWidth: 2,
|
||||
}))`
|
||||
display: flex;
|
||||
@@ -190,8 +194,16 @@ const StreamTitle = styled.div`
|
||||
color: white;
|
||||
text-shadow: 0 0 4px black;
|
||||
letter-spacing: -0.025em;
|
||||
background: ${({ isListening, activeColor }) =>
|
||||
Color(isListening ? activeColor : 'black').alpha(0.5)};
|
||||
background: ${({
|
||||
isListening,
|
||||
activeColor,
|
||||
}: {
|
||||
isListening: boolean
|
||||
activeColor: string
|
||||
}) =>
|
||||
Color(isListening ? activeColor : 'black')
|
||||
.alpha(0.5)
|
||||
.toString()};
|
||||
border-radius: 4px;
|
||||
backdrop-filter: blur(10px);
|
||||
overflow: hidden;
|
||||
@@ -1,4 +1,4 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@@ -9,6 +9,6 @@
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<script src="playHLS.js" type="module"></script>
|
||||
<script src="playHLS.ts" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
19
packages/streamwall/src/renderer/playHLS.ts
Normal 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)
|
||||
}
|
||||
5
packages/streamwall/src/renderer/svg-loaders-react.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
declare module 'svg-loaders-react' {
|
||||
import { FC, SVGProps } from 'react'
|
||||
|
||||
export const TailSpin: FC<SVGProps<SVGSVGElement>>
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export function ensureValidURL(urlStr) {
|
||||
export function ensureValidURL(urlStr: string) {
|
||||
const url = new URL(urlStr)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
throw new Error(`rejecting attempt to load non-http URL '${urlStr}'`)
|
||||
23
packages/streamwall/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
8
packages/streamwall/vite.main.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
// https://vitejs.dev/config
|
||||
export default defineConfig({
|
||||
build: {
|
||||
sourcemap: true,
|
||||
},
|
||||
})
|
||||
8
packages/streamwall/vite.preload.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
// https://vitejs.dev/config
|
||||
export default defineConfig({
|
||||
build: {
|
||||
sourcemap: true,
|
||||
},
|
||||
})
|
||||
34
packages/streamwall/vite.renderer.config.ts
Normal 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[]),
|
||||
],
|
||||
})
|
||||
@@ -3,4 +3,5 @@ module.exports = {
|
||||
tabWidth: 2,
|
||||
semi: false,
|
||||
singleQuote: true,
|
||||
plugins: ['prettier-plugin-organize-imports'],
|
||||
}
|
||||
|
||||
BIN
screenshot.png
|
Before Width: | Height: | Size: 1.3 MiB |
@@ -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)
|
||||
@@ -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)),
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
163
src/node/auth.js
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
32
src/roles.js
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
// })
|
||||
// })
|
||||
@@ -1,3 +0,0 @@
|
||||
import { main } from './control.js';
|
||||
|
||||
main();
|
||||
@@ -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]
|
||||