mirror of
https://github.com/streamwall/streamwall.git
synced 2025-12-06 01:45:37 -05:00
Initial v2 overhaul
This commit is contained in:
24
packages/streamwall-control-ui/.gitignore
vendored
Normal file
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
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"
|
||||
}
|
||||
}
|
||||
1752
packages/streamwall-control-ui/src/index.tsx
Normal file
1752
packages/streamwall-control-ui/src/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
23
packages/streamwall-control-ui/tsconfig.json
Normal file
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
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
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"
|
||||
}
|
||||
}
|
||||
32
packages/streamwall-shared/src/colors.ts
Normal file
32
packages/streamwall-shared/src/colors.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import Color from 'color'
|
||||
|
||||
export function hashText(text: string, range: number) {
|
||||
// DJBX33A-ish
|
||||
// based on https://github.com/euphoria-io/heim/blob/978c921063e6b06012fc8d16d9fbf1b3a0be1191/client/lib/hueHash.js#L16-L45
|
||||
let val = 0
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
// Multiply by an arbitrary prime number to spread out similar letters.
|
||||
const charVal = (text.charCodeAt(i) * 401) % range
|
||||
|
||||
// Multiply val by 33 while constraining within signed 32 bit int range.
|
||||
// this keeps the value within Number.MAX_SAFE_INTEGER without throwing out
|
||||
// information.
|
||||
const origVal = val
|
||||
val = val << 5
|
||||
val += origVal
|
||||
|
||||
// Add the character to the hash.
|
||||
val += charVal
|
||||
}
|
||||
|
||||
return (val + range) % range
|
||||
}
|
||||
|
||||
export function idColor(id: string) {
|
||||
if (!id) {
|
||||
return Color('white')
|
||||
}
|
||||
const h = hashText(id, 360)
|
||||
const sPart = hashText(id, 40)
|
||||
return Color({ h, s: 20 + sPart, l: 50 })
|
||||
}
|
||||
109
packages/streamwall-shared/src/geometry.ts
Normal file
109
packages/streamwall-shared/src/geometry.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { Rectangle } from 'electron'
|
||||
import { isEqual } from 'lodash-es'
|
||||
import { ContentKind } from './types'
|
||||
|
||||
export interface ViewPos extends Rectangle {
|
||||
/**
|
||||
* Grid space indexes inhabited by the view.
|
||||
*/
|
||||
spaces: number[]
|
||||
}
|
||||
|
||||
export interface ViewContent {
|
||||
url: string
|
||||
kind: ContentKind
|
||||
}
|
||||
export type ViewContentMap = Map<string, ViewContent>
|
||||
|
||||
export function boxesFromViewContentMap(
|
||||
width: number,
|
||||
height: number,
|
||||
viewContentMap: ViewContentMap,
|
||||
) {
|
||||
const boxes = []
|
||||
const visited = new Set()
|
||||
|
||||
function isPosContent(
|
||||
x: number,
|
||||
y: number,
|
||||
content: ViewContent | undefined,
|
||||
) {
|
||||
const checkIdx = width * y + x
|
||||
return (
|
||||
!visited.has(checkIdx) &&
|
||||
isEqual(viewContentMap.get(String(checkIdx)), content)
|
||||
)
|
||||
}
|
||||
|
||||
function findLargestBox(x: number, y: number) {
|
||||
const idx = width * y + x
|
||||
const spaces = [idx]
|
||||
const content = viewContentMap.get(String(idx))
|
||||
|
||||
let maxY
|
||||
for (maxY = y + 1; maxY < height; maxY++) {
|
||||
if (!isPosContent(x, maxY, content)) {
|
||||
break
|
||||
}
|
||||
spaces.push(width * maxY + x)
|
||||
}
|
||||
|
||||
let cx = x
|
||||
let cy = y
|
||||
scan: for (cx = x + 1; cx < width; cx++) {
|
||||
for (cy = y; cy < maxY; cy++) {
|
||||
if (!isPosContent(cx, cy, content)) {
|
||||
break scan
|
||||
}
|
||||
}
|
||||
for (let cy = y; cy < maxY; cy++) {
|
||||
spaces.push(width * cy + cx)
|
||||
}
|
||||
}
|
||||
const w = cx - x
|
||||
const h = maxY - y
|
||||
spaces.sort()
|
||||
return { content, x, y, w, h, spaces }
|
||||
}
|
||||
|
||||
for (let y = 0; y < width; y++) {
|
||||
for (let x = 0; x < height; x++) {
|
||||
const idx = width * y + x
|
||||
if (visited.has(idx) || viewContentMap.get(String(idx)) === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
const box = findLargestBox(x, y)
|
||||
boxes.push(box)
|
||||
for (const boxIdx of box.spaces) {
|
||||
visited.add(boxIdx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return boxes
|
||||
}
|
||||
|
||||
export function idxToCoords(gridCount: number, idx: number) {
|
||||
const x = idx % gridCount
|
||||
const y = Math.floor(idx / gridCount)
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
export function idxInBox(
|
||||
gridCount: number,
|
||||
start: number,
|
||||
end: number,
|
||||
idx: number,
|
||||
) {
|
||||
const { x: startX, y: startY } = idxToCoords(gridCount, start)
|
||||
const { x: endX, y: endY } = idxToCoords(gridCount, end)
|
||||
const { x, y } = idxToCoords(gridCount, idx)
|
||||
const lowX = Math.min(startX, endX)
|
||||
const highX = Math.max(startX, endX)
|
||||
const lowY = Math.min(startY, endY)
|
||||
const highY = Math.max(startY, endY)
|
||||
const xInBox = x >= lowX && x <= highX
|
||||
const yInBox = y >= lowY && y <= highY
|
||||
return xInBox && yInBox
|
||||
}
|
||||
4
packages/streamwall-shared/src/index.ts
Normal file
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
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
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
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
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
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
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
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
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
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),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
74
packages/streamwall/src/main/StreamdelayClient.ts
Normal file
74
packages/streamwall/src/main/StreamdelayClient.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import assert from 'assert'
|
||||
import EventEmitter from 'events'
|
||||
import ReconnectingWebSocket from 'reconnecting-websocket'
|
||||
import { StreamDelayStatus } from 'streamwall-shared'
|
||||
import * as url from 'url'
|
||||
import WebSocket from 'ws'
|
||||
|
||||
export interface StreamdelayClientOptions {
|
||||
endpoint: string
|
||||
key: string
|
||||
}
|
||||
|
||||
export default class StreamdelayClient extends EventEmitter {
|
||||
endpoint: string
|
||||
key: string
|
||||
ws: ReconnectingWebSocket | null
|
||||
status: StreamDelayStatus | null
|
||||
|
||||
constructor({ endpoint, key }: StreamdelayClientOptions) {
|
||||
super()
|
||||
this.endpoint = endpoint
|
||||
this.key = key
|
||||
this.ws = null
|
||||
this.status = null
|
||||
}
|
||||
|
||||
connect() {
|
||||
const wsURL = url.resolve(
|
||||
this.endpoint.replace(/^http/, 'ws'),
|
||||
`ws?key=${this.key}`,
|
||||
)
|
||||
const ws = (this.ws = new ReconnectingWebSocket(wsURL, [], {
|
||||
WebSocket,
|
||||
maxReconnectionDelay: 5000,
|
||||
minReconnectionDelay: 1000 + Math.random() * 500,
|
||||
reconnectionDelayGrowFactor: 1.1,
|
||||
}))
|
||||
ws.addEventListener('open', () => this.emitState())
|
||||
ws.addEventListener('close', () => this.emitState())
|
||||
ws.addEventListener('message', (ev) => {
|
||||
let data
|
||||
try {
|
||||
data = JSON.parse(ev.data)
|
||||
} catch (err) {
|
||||
console.error('invalid JSON from streamdelay:', ev.data)
|
||||
return
|
||||
}
|
||||
this.status = data.status
|
||||
this.emitState()
|
||||
})
|
||||
}
|
||||
|
||||
emitState() {
|
||||
const isConnected = this.ws?.readyState === WebSocket.OPEN
|
||||
if (isConnected && !this.status) {
|
||||
// Wait until we've received the first status message
|
||||
return
|
||||
}
|
||||
this.emit('state', {
|
||||
isConnected,
|
||||
...this.status,
|
||||
})
|
||||
}
|
||||
|
||||
setCensored(isCensored: boolean) {
|
||||
assert(this.ws != null, 'Must be connected')
|
||||
this.ws.send(JSON.stringify({ isCensored }))
|
||||
}
|
||||
|
||||
setStreamRunning(isStreamRunning: boolean) {
|
||||
assert(this.ws != null, 'Must be connected')
|
||||
this.ws.send(JSON.stringify({ isStreamRunning }))
|
||||
}
|
||||
}
|
||||
163
packages/streamwall/src/main/data.ts
Normal file
163
packages/streamwall/src/main/data.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import TOML from '@iarna/toml'
|
||||
import { Repeater } from '@repeaterjs/repeater'
|
||||
import { watch } from 'chokidar'
|
||||
import { EventEmitter, once } from 'events'
|
||||
import { promises as fsPromises } from 'fs'
|
||||
import { isArray } from 'lodash-es'
|
||||
import fetch from 'node-fetch'
|
||||
import { promisify } from 'util'
|
||||
import { StreamData, StreamList } from '../../../streamwall-shared/src/types'
|
||||
|
||||
const sleep = promisify(setTimeout)
|
||||
|
||||
type DataSource = AsyncGenerator<StreamData[]>
|
||||
|
||||
export async function* pollDataURL(url: string, intervalSecs: number) {
|
||||
const refreshInterval = intervalSecs * 1000
|
||||
let lastData = []
|
||||
while (true) {
|
||||
let data: StreamData[] = []
|
||||
try {
|
||||
const resp = await fetch(url)
|
||||
data = (await resp.json()) as StreamData[]
|
||||
} catch (err) {
|
||||
console.warn('error loading stream data', err)
|
||||
}
|
||||
|
||||
// If the endpoint errors or returns an empty dataset, keep the cached data.
|
||||
if (!data.length && lastData.length) {
|
||||
console.warn('using cached stream data')
|
||||
} else {
|
||||
yield data
|
||||
lastData = data
|
||||
}
|
||||
|
||||
await sleep(refreshInterval)
|
||||
}
|
||||
}
|
||||
|
||||
export async function* watchDataFile(path: string): DataSource {
|
||||
const watcher = watch(path)
|
||||
while (true) {
|
||||
let data
|
||||
try {
|
||||
const text = await fsPromises.readFile(path)
|
||||
data = TOML.parse(text.toString())
|
||||
} catch (err) {
|
||||
console.warn('error reading data file', err)
|
||||
}
|
||||
if (data && isArray(data.streams)) {
|
||||
// TODO: type validate with Zod
|
||||
yield data.streams as unknown as StreamList
|
||||
} else {
|
||||
yield []
|
||||
}
|
||||
await once(watcher, 'change')
|
||||
}
|
||||
}
|
||||
|
||||
export async function* markDataSource(dataSource: DataSource, name: string) {
|
||||
for await (const streamList of dataSource) {
|
||||
for (const s of streamList) {
|
||||
s._dataSource = name
|
||||
}
|
||||
yield streamList
|
||||
}
|
||||
}
|
||||
|
||||
export async function* combineDataSources(dataSources: DataSource[]) {
|
||||
for await (const streamLists of Repeater.latest(dataSources)) {
|
||||
const dataByURL = new Map<string, StreamData>()
|
||||
for (const list of streamLists) {
|
||||
for (const data of list) {
|
||||
const existing = dataByURL.get(data.link)
|
||||
dataByURL.set(data.link, { ...existing, ...data })
|
||||
}
|
||||
}
|
||||
const streams: StreamList = [...dataByURL.values()]
|
||||
// Retain the index to speed up local lookups
|
||||
streams.byURL = dataByURL
|
||||
yield streams
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalStreamData extends EventEmitter {
|
||||
dataByURL: Map<string, Partial<StreamData>>
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.dataByURL = new Map()
|
||||
}
|
||||
|
||||
update(url: string, data: Partial<StreamData>) {
|
||||
const existing = this.dataByURL.get(url)
|
||||
this.dataByURL.set(data.link ?? url, { ...existing, ...data, link: url })
|
||||
if (data.link != null && url !== data.link) {
|
||||
this.dataByURL.delete(url)
|
||||
}
|
||||
this._emitUpdate()
|
||||
}
|
||||
|
||||
delete(url: string) {
|
||||
this.dataByURL.delete(url)
|
||||
this._emitUpdate()
|
||||
}
|
||||
|
||||
_emitUpdate() {
|
||||
this.emit('update', [...this.dataByURL.values()])
|
||||
}
|
||||
|
||||
gen(): AsyncGenerator<StreamData[]> {
|
||||
return new Repeater(async (push, stop) => {
|
||||
await push([])
|
||||
this.on('update', push)
|
||||
await stop
|
||||
this.off('update', push)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class StreamIDGenerator {
|
||||
idMap: Map<string, string>
|
||||
idSet: Set<string>
|
||||
|
||||
constructor() {
|
||||
this.idMap = new Map()
|
||||
this.idSet = new Set()
|
||||
}
|
||||
|
||||
process(streams: StreamData[]) {
|
||||
const { idMap, idSet } = this
|
||||
|
||||
for (const stream of streams) {
|
||||
const { link, source, label } = stream
|
||||
let streamId = idMap.get(link)
|
||||
if (streamId == null) {
|
||||
let counter = 0
|
||||
let newId
|
||||
const idBase = source || label || link
|
||||
if (!idBase) {
|
||||
console.warn('skipping empty stream', stream)
|
||||
continue
|
||||
}
|
||||
const normalizedText = idBase
|
||||
.toLowerCase()
|
||||
.replace(/[^\w]/g, '')
|
||||
.replace(/^the|^https?(www)?/, '')
|
||||
do {
|
||||
const textPart = normalizedText.substr(0, 3).toLowerCase()
|
||||
const counterPart = counter === 0 && textPart ? '' : counter
|
||||
newId = `${textPart}${counterPart}`
|
||||
counter++
|
||||
} while (idSet.has(newId))
|
||||
|
||||
streamId = newId
|
||||
idMap.set(link, streamId)
|
||||
idSet.add(streamId)
|
||||
}
|
||||
|
||||
stream._id = streamId
|
||||
}
|
||||
return streams
|
||||
}
|
||||
}
|
||||
477
packages/streamwall/src/main/index.ts
Normal file
477
packages/streamwall/src/main/index.ts
Normal file
@@ -0,0 +1,477 @@
|
||||
import TOML from '@iarna/toml'
|
||||
import * as Sentry from '@sentry/electron/main'
|
||||
import { BrowserWindow, app, session } from 'electron'
|
||||
import started from 'electron-squirrel-startup'
|
||||
import fs from 'fs'
|
||||
import 'source-map-support/register'
|
||||
import { ControlCommand, StreamwallState } from 'streamwall-shared'
|
||||
import { updateElectronApp } from 'update-electron-app'
|
||||
import yargs from 'yargs'
|
||||
import * as Y from 'yjs'
|
||||
import { ensureValidURL } from '../util'
|
||||
import ControlWindow from './ControlWindow'
|
||||
import {
|
||||
LocalStreamData,
|
||||
StreamIDGenerator,
|
||||
combineDataSources,
|
||||
markDataSource,
|
||||
pollDataURL,
|
||||
watchDataFile,
|
||||
} from './data'
|
||||
import StreamdelayClient from './StreamdelayClient'
|
||||
import StreamWindow from './StreamWindow'
|
||||
|
||||
const SENTRY_DSN =
|
||||
'https://e630a21dcf854d1a9eb2a7a8584cbd0b@o459879.ingest.sentry.io/5459505'
|
||||
|
||||
export interface StreamwallConfig {
|
||||
help: boolean
|
||||
grid: {
|
||||
count: number
|
||||
}
|
||||
window: {
|
||||
x?: number
|
||||
y?: number
|
||||
width: number
|
||||
height: number
|
||||
frameless: boolean
|
||||
'background-color': string
|
||||
'active-color': string
|
||||
}
|
||||
data: {
|
||||
interval: number
|
||||
'json-url': string[]
|
||||
'toml-file': string[]
|
||||
}
|
||||
streamdelay: {
|
||||
endpoint: string
|
||||
key: string | null
|
||||
}
|
||||
telemetry: {
|
||||
sentry: boolean
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(): StreamwallConfig {
|
||||
return (
|
||||
yargs()
|
||||
.config('config', (configPath) => {
|
||||
return TOML.parse(fs.readFileSync(configPath, 'utf-8'))
|
||||
})
|
||||
.group(['grid.count'], 'Grid dimensions')
|
||||
.option('grid.count', {
|
||||
number: true,
|
||||
default: 3,
|
||||
})
|
||||
.group(
|
||||
[
|
||||
'window.width',
|
||||
'window.height',
|
||||
'window.x',
|
||||
'window.y',
|
||||
'window.frameless',
|
||||
'window.background-color',
|
||||
'window.active-color',
|
||||
],
|
||||
'Window settings',
|
||||
)
|
||||
.option('window.x', {
|
||||
number: true,
|
||||
})
|
||||
.option('window.y', {
|
||||
number: true,
|
||||
})
|
||||
.option('window.width', {
|
||||
number: true,
|
||||
default: 1920,
|
||||
})
|
||||
.option('window.height', {
|
||||
number: true,
|
||||
default: 1080,
|
||||
})
|
||||
.option('window.frameless', {
|
||||
boolean: true,
|
||||
default: false,
|
||||
})
|
||||
.option('window.background-color', {
|
||||
describe: 'Background color of wall (useful for chroma-keying)',
|
||||
default: '#000',
|
||||
})
|
||||
.option('window.active-color', {
|
||||
describe: 'Active (highlight) color of wall',
|
||||
default: '#fff',
|
||||
})
|
||||
.group(
|
||||
['data.interval', 'data.json-url', 'data.toml-file'],
|
||||
'Datasources',
|
||||
)
|
||||
.option('data.interval', {
|
||||
describe: 'Interval (in seconds) for refreshing polled data sources',
|
||||
number: true,
|
||||
default: 30,
|
||||
})
|
||||
.option('data.json-url', {
|
||||
describe: 'Fetch streams from the specified URL(s)',
|
||||
array: true,
|
||||
string: true,
|
||||
default: [],
|
||||
})
|
||||
.option('data.toml-file', {
|
||||
describe: 'Fetch streams from the specified file(s)',
|
||||
normalize: true,
|
||||
array: true,
|
||||
default: [],
|
||||
})
|
||||
/*
|
||||
.group(
|
||||
[
|
||||
'control.username',
|
||||
'control.password',
|
||||
'control.address',
|
||||
'control.hostname',
|
||||
'control.port',
|
||||
'control.open',
|
||||
],
|
||||
'Control Webserver',
|
||||
)
|
||||
.option('control.username', {
|
||||
describe: 'Web control server username',
|
||||
})
|
||||
.option('control.password', {
|
||||
describe: 'Web control server password',
|
||||
})
|
||||
.option('control.open', {
|
||||
describe: 'After launching, open the control website in a browser',
|
||||
boolean: true,
|
||||
default: true,
|
||||
})
|
||||
.option('control.address', {
|
||||
describe: 'Enable control webserver and specify the URL',
|
||||
implies: ['control.username', 'control.password'],
|
||||
string: true,
|
||||
})
|
||||
.option('control.hostname', {
|
||||
describe: 'Override hostname the control server listens on',
|
||||
})
|
||||
.option('control.port', {
|
||||
describe: 'Override port the control server listens on',
|
||||
number: true,
|
||||
})
|
||||
.group(
|
||||
['cert.dir', 'cert.production', 'cert.email'],
|
||||
'Automatic SSL Certificate',
|
||||
)
|
||||
.option('cert.dir', {
|
||||
describe: 'Private directory to store SSL certificate in',
|
||||
implies: ['email'],
|
||||
default: null,
|
||||
})
|
||||
.option('cert.production', {
|
||||
describe: 'Obtain a real SSL certificate using production servers',
|
||||
})
|
||||
.option('cert.email', {
|
||||
describe: 'Email for owner of SSL certificate',
|
||||
})
|
||||
*/
|
||||
.group(['streamdelay.endpoint', 'streamdelay.key'], 'Streamdelay')
|
||||
.option('streamdelay.endpoint', {
|
||||
describe: 'URL of Streamdelay endpoint',
|
||||
default: 'http://localhost:8404',
|
||||
})
|
||||
.option('streamdelay.key', {
|
||||
describe: 'Streamdelay API key',
|
||||
default: null,
|
||||
})
|
||||
.group(['telemetry.sentry'], 'Telemetry')
|
||||
.option('telemetry.sentry', {
|
||||
describe: 'Enable error reporting to Sentry',
|
||||
boolean: true,
|
||||
default: true,
|
||||
})
|
||||
.help()
|
||||
// https://github.com/yargs/yargs/issues/2137
|
||||
.parseSync() as unknown as StreamwallConfig
|
||||
)
|
||||
}
|
||||
|
||||
async function main(argv: ReturnType<typeof parseArgs>) {
|
||||
// Reject all permission requests from web content.
|
||||
session
|
||||
.fromPartition('persist:session')
|
||||
.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||
callback(false)
|
||||
})
|
||||
|
||||
console.debug('Creating StreamWindow...')
|
||||
const idGen = new StreamIDGenerator()
|
||||
const localStreamData = new LocalStreamData()
|
||||
const overlayStreamData = new LocalStreamData()
|
||||
|
||||
const streamWindowConfig = {
|
||||
gridCount: argv.grid.count,
|
||||
width: argv.window.width,
|
||||
height: argv.window.height,
|
||||
x: argv.window.x,
|
||||
y: argv.window.y,
|
||||
frameless: argv.window.frameless,
|
||||
activeColor: argv.window['active-color'],
|
||||
backgroundColor: argv.window['background-color'],
|
||||
}
|
||||
const streamWindow = new StreamWindow(streamWindowConfig)
|
||||
const controlWindow = new ControlWindow()
|
||||
|
||||
let browseWindow: BrowserWindow | null = null
|
||||
let streamdelayClient: StreamdelayClient | null = null
|
||||
|
||||
console.debug('Creating initial state...')
|
||||
let clientState: StreamwallState = {
|
||||
config: streamWindowConfig,
|
||||
streams: [],
|
||||
views: [],
|
||||
streamdelay: null,
|
||||
}
|
||||
|
||||
const stateDoc = new Y.Doc()
|
||||
const viewsState = stateDoc.getMap<Y.Map<string | undefined>>('views')
|
||||
stateDoc.transact(() => {
|
||||
for (let i = 0; i < argv.grid.count ** 2; i++) {
|
||||
const data = new Y.Map<string | undefined>()
|
||||
data.set('streamId', undefined)
|
||||
viewsState.set(String(i), data)
|
||||
}
|
||||
})
|
||||
viewsState.observeDeep(() => {
|
||||
try {
|
||||
const viewContentMap = new Map()
|
||||
for (const [key, viewData] of viewsState) {
|
||||
const streamId = viewData.get('streamId')
|
||||
const stream = clientState.streams.find((s) => s._id === streamId)
|
||||
if (!stream) {
|
||||
continue
|
||||
}
|
||||
viewContentMap.set(key, {
|
||||
url: stream.link,
|
||||
kind: stream.kind || 'video',
|
||||
})
|
||||
}
|
||||
streamWindow.setViews(viewContentMap, clientState.streams)
|
||||
} catch (err) {
|
||||
console.error('Error updating views', err)
|
||||
}
|
||||
})
|
||||
|
||||
const onCommand = async (msg: ControlCommand) => {
|
||||
console.debug('Received message:', msg)
|
||||
if (msg.type === 'set-listening-view') {
|
||||
console.debug('Setting listening view:', msg.viewIdx)
|
||||
streamWindow.setListeningView(msg.viewIdx)
|
||||
} else if (msg.type === 'set-view-background-listening') {
|
||||
console.debug(
|
||||
'Setting view background listening:',
|
||||
msg.viewIdx,
|
||||
msg.listening,
|
||||
)
|
||||
streamWindow.setViewBackgroundListening(msg.viewIdx, msg.listening)
|
||||
} else if (msg.type === 'set-view-blurred') {
|
||||
console.debug('Setting view blurred:', msg.viewIdx, msg.blurred)
|
||||
streamWindow.setViewBlurred(msg.viewIdx, msg.blurred)
|
||||
} else if (msg.type === 'rotate-stream') {
|
||||
console.debug('Rotating stream:', msg.url, msg.rotation)
|
||||
overlayStreamData.update(msg.url, {
|
||||
rotation: msg.rotation,
|
||||
})
|
||||
} else if (msg.type === 'update-custom-stream') {
|
||||
console.debug('Updating custom stream:', msg.url)
|
||||
localStreamData.update(msg.url, msg.data)
|
||||
} else if (msg.type === 'delete-custom-stream') {
|
||||
console.debug('Deleting custom stream:', msg.url)
|
||||
localStreamData.delete(msg.url)
|
||||
} else if (msg.type === 'reload-view') {
|
||||
console.debug('Reloading view:', msg.viewIdx)
|
||||
streamWindow.reloadView(msg.viewIdx)
|
||||
} else if (msg.type === 'browse' || msg.type === 'dev-tools') {
|
||||
if (
|
||||
msg.type === 'dev-tools' &&
|
||||
browseWindow &&
|
||||
!browseWindow.isDestroyed()
|
||||
) {
|
||||
// DevTools needs a fresh webContents to work. Close any existing window.
|
||||
browseWindow.destroy()
|
||||
browseWindow = null
|
||||
}
|
||||
if (!browseWindow || browseWindow.isDestroyed()) {
|
||||
browseWindow = new BrowserWindow({
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
partition: 'persist:session',
|
||||
sandbox: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (msg.type === 'browse') {
|
||||
console.debug('Attempting to browse URL:', msg.url)
|
||||
try {
|
||||
ensureValidURL(msg.url)
|
||||
browseWindow.loadURL(msg.url)
|
||||
} catch (error) {
|
||||
console.error('Invalid URL:', msg.url)
|
||||
console.error('Error:', error)
|
||||
}
|
||||
} else if (msg.type === 'dev-tools') {
|
||||
console.debug('Opening DevTools for view:', msg.viewIdx)
|
||||
streamWindow.openDevTools(msg.viewIdx, browseWindow.webContents)
|
||||
}
|
||||
} else if (msg.type === 'set-stream-censored' && streamdelayClient) {
|
||||
console.debug('Setting stream censored:', msg.isCensored)
|
||||
streamdelayClient.setCensored(msg.isCensored)
|
||||
} else if (msg.type === 'set-stream-running' && streamdelayClient) {
|
||||
console.debug('Setting stream running:', msg.isStreamRunning)
|
||||
streamdelayClient.setStreamRunning(msg.isStreamRunning)
|
||||
// TODO: Move to control server
|
||||
/*} else if (msg.type === 'create-invite') {
|
||||
console.debug('Creating invite for role:', msg.role)
|
||||
const { secret } = await auth.createToken({
|
||||
kind: 'invite',
|
||||
role: msg.role,
|
||||
name: msg.name,
|
||||
})
|
||||
respond({ name: msg.name, secret })
|
||||
} else if (msg.type === 'delete-token') {
|
||||
console.debug('Deleting token:', msg.tokenId)
|
||||
auth.deleteToken(msg.tokenId)
|
||||
*/
|
||||
}
|
||||
}
|
||||
|
||||
function updateState(newState: Partial<StreamwallState>) {
|
||||
clientState = { ...clientState, ...newState }
|
||||
streamWindow.onState(clientState)
|
||||
controlWindow.onState(clientState)
|
||||
}
|
||||
|
||||
// Wire up IPC:
|
||||
|
||||
// StreamWindow view updates -> main
|
||||
streamWindow.on('state', (viewStates) => {
|
||||
updateState({ views: viewStates })
|
||||
})
|
||||
|
||||
// StreamWindow <- main init state
|
||||
streamWindow.on('load', () => {
|
||||
streamWindow.onState(clientState)
|
||||
})
|
||||
|
||||
// Control <- main collab updates
|
||||
stateDoc.on('update', (update) => {
|
||||
controlWindow.onYDocUpdate(update)
|
||||
})
|
||||
|
||||
// Control <- main init state
|
||||
controlWindow.on('load', () => {
|
||||
controlWindow.onState(clientState)
|
||||
controlWindow.onYDocUpdate(Y.encodeStateAsUpdate(stateDoc))
|
||||
})
|
||||
|
||||
// Control -> main
|
||||
controlWindow.on('ydoc', (update) => Y.applyUpdate(stateDoc, update))
|
||||
controlWindow.on('command', (command) => onCommand(command))
|
||||
|
||||
// TODO: Hide on macOS, allow reopening from dock
|
||||
streamWindow.on('close', () => {
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
if (argv.streamdelay.key) {
|
||||
console.debug('Setting up Streamdelay client...')
|
||||
streamdelayClient = new StreamdelayClient({
|
||||
endpoint: argv.streamdelay.endpoint,
|
||||
key: argv.streamdelay.key,
|
||||
})
|
||||
streamdelayClient.on('state', (state) => {
|
||||
updateState({ streamdelay: state })
|
||||
})
|
||||
streamdelayClient.connect()
|
||||
}
|
||||
|
||||
/*
|
||||
if (argv.control.address) {
|
||||
console.debug('Initializing web server...')
|
||||
const webDistPath = path.join(app.getAppPath(), 'web')
|
||||
await initWebServer({
|
||||
certDir: argv.cert.dir,
|
||||
certProduction: argv.cert.production,
|
||||
email: argv.cert.email,
|
||||
url: argv.control.address,
|
||||
hostname: argv.control.hostname,
|
||||
port: argv.control.port,
|
||||
logEnabled: true,
|
||||
webDistPath,
|
||||
auth,
|
||||
clientState,
|
||||
onMessage,
|
||||
stateDoc,
|
||||
})
|
||||
if (argv.control.open) {
|
||||
shell.openExternal(argv.control.address)
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
const dataSources = [
|
||||
...argv.data['json-url'].map((url) => {
|
||||
console.debug('Setting data source from json-url:', url)
|
||||
return markDataSource(pollDataURL(url, argv.data.interval), 'json-url')
|
||||
}),
|
||||
...argv.data['toml-file'].map((path) => {
|
||||
console.debug('Setting data source from toml-file:', path)
|
||||
return markDataSource(watchDataFile(path), 'toml-file')
|
||||
}),
|
||||
markDataSource(localStreamData.gen(), 'custom'),
|
||||
overlayStreamData.gen(),
|
||||
]
|
||||
|
||||
for await (const rawStreams of combineDataSources(dataSources)) {
|
||||
console.debug('Processing streams:', rawStreams)
|
||||
const streams = idGen.process(rawStreams)
|
||||
updateState({ streams })
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
console.debug('Parsing command line arguments...')
|
||||
const argv = parseArgs()
|
||||
if (argv.help) {
|
||||
return
|
||||
}
|
||||
|
||||
console.debug('Initializing Sentry...')
|
||||
if (argv.telemetry.sentry) {
|
||||
Sentry.init({ dsn: SENTRY_DSN })
|
||||
}
|
||||
|
||||
updateElectronApp()
|
||||
|
||||
console.debug('Setting up Electron...')
|
||||
app.commandLine.appendSwitch('high-dpi-support', '1')
|
||||
app.commandLine.appendSwitch('force-device-scale-factor', '1')
|
||||
|
||||
console.debug('Enabling Electron sandbox...')
|
||||
app.enableSandbox()
|
||||
|
||||
app
|
||||
.whenReady()
|
||||
.then(() => main(argv))
|
||||
.catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||
if (started) {
|
||||
app.quit()
|
||||
}
|
||||
|
||||
console.debug('Starting Streamwall...')
|
||||
init()
|
||||
24
packages/streamwall/src/main/loadHTML.ts
Normal file
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
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
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
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)
|
||||
270
packages/streamwall/src/preload/mediaPreload.ts
Normal file
270
packages/streamwall/src/preload/mediaPreload.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { ipcRenderer, webFrame } from 'electron'
|
||||
import throttle from 'lodash/throttle'
|
||||
import { ContentDisplayOptions } from 'streamwall-shared'
|
||||
|
||||
const SCAN_THROTTLE = 500
|
||||
|
||||
const VIDEO_OVERRIDE_STYLE = `
|
||||
* {
|
||||
pointer-events: none;
|
||||
display: none !important;
|
||||
position: static !important;
|
||||
z-index: 0 !important;
|
||||
}
|
||||
html, body, video, audio {
|
||||
display: block !important;
|
||||
background: black !important;
|
||||
}
|
||||
html, body {
|
||||
overflow: hidden !important;
|
||||
background: black !important;
|
||||
}
|
||||
video, iframe.__video__, audio {
|
||||
display: block !important;
|
||||
position: fixed !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
top: 0 !important;
|
||||
bottom: 0 !important;
|
||||
width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
object-fit: cover !important;
|
||||
transition: none !important;
|
||||
z-index: 999999 !important;
|
||||
}
|
||||
audio {
|
||||
z-index: 999998 !important;
|
||||
}
|
||||
.__video_parent__ {
|
||||
display: block !important;
|
||||
}
|
||||
video.__rot180__ {
|
||||
transform: rotate(180deg) !important;
|
||||
}
|
||||
/* For 90 degree rotations, we position the video with swapped width and height and rotate it into place.
|
||||
It's helpful to offset the video so the transformation is centered in the viewport center.
|
||||
We move the video top left corner to center of the page and then translate half the video dimensions up and left.
|
||||
Note that the width and height are swapped in the translate because the video starts with the side dimensions swapped. */
|
||||
video.__rot90__ {
|
||||
transform: translate(-50vh, -50vw) rotate(90deg) !important;
|
||||
}
|
||||
video.__rot270__ {
|
||||
transform: translate(-50vh, -50vw) rotate(270deg) !important;
|
||||
}
|
||||
video.__rot90__, video.__rot270__ {
|
||||
left: 50vw !important;
|
||||
top: 50vh !important;
|
||||
width: 100vh !important;
|
||||
height: 100vw !important;
|
||||
}
|
||||
`
|
||||
|
||||
const NO_SCROLL_STYLE = `
|
||||
html, body {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
`
|
||||
|
||||
const sleep = (ms: number) =>
|
||||
new Promise<void>((resolve) => setTimeout(() => resolve(), ms))
|
||||
|
||||
const pageReady = new Promise((resolve) =>
|
||||
document.addEventListener('DOMContentLoaded', resolve, { once: true }),
|
||||
)
|
||||
|
||||
class RotationController {
|
||||
video: HTMLVideoElement
|
||||
siteRotation: number
|
||||
customRotation: number
|
||||
|
||||
constructor(video: HTMLVideoElement) {
|
||||
this.video = video
|
||||
this.siteRotation = 0
|
||||
this.customRotation = 0
|
||||
}
|
||||
|
||||
_update() {
|
||||
const rotation = (this.siteRotation + this.customRotation) % 360
|
||||
if (![0, 90, 180, 270].includes(rotation)) {
|
||||
console.warn('ignoring invalid rotation', rotation)
|
||||
}
|
||||
this.video.className = `__rot${rotation}__`
|
||||
}
|
||||
|
||||
setSite(rotation = 0) {
|
||||
this.siteRotation = rotation
|
||||
this._update()
|
||||
}
|
||||
|
||||
setCustom(rotation = 0) {
|
||||
this.customRotation = rotation
|
||||
this._update()
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for media tags and mute them as soon as possible.
|
||||
async function lockdownMediaTags() {
|
||||
const lockdown = throttle(() => {
|
||||
webFrame.executeJavaScript(`
|
||||
for (const el of document.querySelectorAll('video, audio')) {
|
||||
if (el.__sw) {
|
||||
continue
|
||||
}
|
||||
// Prevent sites from re-muting the video
|
||||
Object.defineProperty(el, 'muted', { writable: true, value: false })
|
||||
// Prevent Facebook from pausing the video after page load.
|
||||
Object.defineProperty(el, 'pause', { writable: false, value: () => {} })
|
||||
el.__sw = true
|
||||
}
|
||||
`)
|
||||
}, SCAN_THROTTLE)
|
||||
await pageReady
|
||||
const observer = new MutationObserver(lockdown)
|
||||
observer.observe(document.body, { subtree: true, childList: true })
|
||||
}
|
||||
|
||||
async function waitForQuery(query: string): Promise<Element> {
|
||||
console.log(`waiting for '${query}'...`)
|
||||
await pageReady
|
||||
return new Promise((resolve) => {
|
||||
const scan = throttle(() => {
|
||||
const el = document.querySelector(query)
|
||||
if (el) {
|
||||
console.log(`found '${query}'`)
|
||||
resolve(el)
|
||||
observer.disconnect()
|
||||
}
|
||||
}, SCAN_THROTTLE)
|
||||
|
||||
const observer = new MutationObserver(scan)
|
||||
observer.observe(document.body, { subtree: true, childList: true })
|
||||
scan()
|
||||
})
|
||||
}
|
||||
|
||||
async function waitForVideo(kind: 'video' | 'audio'): Promise<{
|
||||
video?: HTMLMediaElement
|
||||
iframe?: HTMLIFrameElement
|
||||
}> {
|
||||
lockdownMediaTags()
|
||||
|
||||
let video: Element | null | void = await Promise.race([
|
||||
waitForQuery(kind),
|
||||
sleep(10 * 1000),
|
||||
])
|
||||
if (video instanceof HTMLMediaElement) {
|
||||
return { video }
|
||||
}
|
||||
|
||||
let iframe
|
||||
for (iframe of document.querySelectorAll('iframe')) {
|
||||
video = iframe.contentDocument?.querySelector?.(kind)
|
||||
if (video instanceof HTMLVideoElement) {
|
||||
return { video, iframe }
|
||||
}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
const igHacks = {
|
||||
isMatch() {
|
||||
return location.host === 'www.instagram.com'
|
||||
},
|
||||
async onLoad() {
|
||||
const playButton = await Promise.race([
|
||||
waitForQuery('button'),
|
||||
waitForQuery('video'),
|
||||
sleep(1000),
|
||||
])
|
||||
if (
|
||||
playButton instanceof HTMLButtonElement &&
|
||||
playButton.tagName === 'BUTTON' &&
|
||||
playButton.textContent === 'Tap to play'
|
||||
) {
|
||||
playButton.click()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async function findVideo(kind: 'video' | 'audio') {
|
||||
if (igHacks.isMatch()) {
|
||||
await igHacks.onLoad()
|
||||
}
|
||||
|
||||
const { video, iframe } = await waitForVideo(kind)
|
||||
if (!video) {
|
||||
throw new Error('could not find video')
|
||||
}
|
||||
if (iframe && iframe.contentDocument) {
|
||||
// TODO: verify iframe still works
|
||||
const style = iframe.contentDocument.createElement('style')
|
||||
style.innerHTML = VIDEO_OVERRIDE_STYLE
|
||||
iframe.contentDocument.head.appendChild(style)
|
||||
iframe.className = '__video__'
|
||||
let parentEl = iframe.parentElement
|
||||
while (parentEl) {
|
||||
parentEl.className = '__video_parent__'
|
||||
parentEl = parentEl.parentElement
|
||||
}
|
||||
iframe.contentDocument.body.appendChild(video)
|
||||
} else {
|
||||
document.body.appendChild(video)
|
||||
}
|
||||
|
||||
video.play()
|
||||
|
||||
if (video instanceof HTMLVideoElement && !video.videoWidth) {
|
||||
console.log(`video isn't playing yet. waiting for it to start...`)
|
||||
const videoReady = new Promise((resolve) =>
|
||||
video.addEventListener('playing', resolve, { once: true }),
|
||||
)
|
||||
await Promise.race([videoReady, sleep(10 * 1000)])
|
||||
if (!video.videoWidth) {
|
||||
throw new Error('timeout waiting for video to start')
|
||||
}
|
||||
console.log('video started')
|
||||
}
|
||||
|
||||
video.muted = false
|
||||
|
||||
const info = {
|
||||
title: document.title,
|
||||
}
|
||||
return { info, video }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const viewInit = ipcRenderer.invoke('view-init')
|
||||
const pageReady = new Promise((resolve) => process.once('loaded', resolve))
|
||||
|
||||
const [{ content, options: initialOptions }] = await Promise.all([
|
||||
viewInit,
|
||||
pageReady,
|
||||
])
|
||||
|
||||
let rotationController: RotationController | undefined
|
||||
if (content.kind === 'video' || content.kind === 'audio') {
|
||||
webFrame.insertCSS(VIDEO_OVERRIDE_STYLE, { cssOrigin: 'user' })
|
||||
const { info, video } = await findVideo(content.kind)
|
||||
if (content.kind === 'video' && video instanceof HTMLVideoElement) {
|
||||
rotationController = new RotationController(video)
|
||||
}
|
||||
ipcRenderer.send('view-info', { info })
|
||||
} else if (content.kind === 'web') {
|
||||
webFrame.insertCSS(NO_SCROLL_STYLE, { cssOrigin: 'user' })
|
||||
}
|
||||
|
||||
ipcRenderer.send('view-loaded')
|
||||
|
||||
function updateOptions(options: ContentDisplayOptions) {
|
||||
if (rotationController) {
|
||||
rotationController.setCustom(options.rotation)
|
||||
}
|
||||
}
|
||||
ipcRenderer.on('options', (ev, options) => updateOptions(options))
|
||||
updateOptions(initialOptions)
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
ipcRenderer.send('view-error', { error })
|
||||
})
|
||||
14
packages/streamwall/src/renderer/background.html
Normal file
14
packages/streamwall/src/renderer/background.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Streamwall Stream Background</title>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; style-src 'self' 'unsafe-inline'; frame-src *"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<script src="background.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
55
packages/streamwall/src/renderer/background.tsx
Normal file
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
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
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
3
packages/streamwall/src/renderer/index.css
Normal file
@@ -0,0 +1,3 @@
|
||||
body {
|
||||
font-family: 'Noto Sans';
|
||||
}
|
||||
14
packages/streamwall/src/renderer/overlay.html
Normal file
14
packages/streamwall/src/renderer/overlay.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Streamwall Stream Overlay</title>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; style-src 'self' 'unsafe-inline'; frame-src *"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<script src="overlay.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
266
packages/streamwall/src/renderer/overlay.tsx
Normal file
266
packages/streamwall/src/renderer/overlay.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import Color from 'color'
|
||||
import { render } from 'preact'
|
||||
import { useEffect, useState } from 'preact/hooks'
|
||||
import { useHotkeys } from 'react-hotkeys-hook'
|
||||
import {
|
||||
FaFacebook,
|
||||
FaInstagram,
|
||||
FaTiktok,
|
||||
FaTwitch,
|
||||
FaVolumeUp,
|
||||
FaYoutube,
|
||||
} from 'react-icons/fa'
|
||||
import { StreamwallState } from 'streamwall-shared'
|
||||
import { styled } from 'styled-components'
|
||||
import { TailSpin } from 'svg-loaders-react'
|
||||
import { matchesState } from 'xstate'
|
||||
import { StreamwallLayerGlobal } from '../preload/layerPreload'
|
||||
|
||||
import '@fontsource/noto-sans'
|
||||
import './index.css'
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
streamwallLayer: StreamwallLayerGlobal
|
||||
}
|
||||
}
|
||||
|
||||
function Overlay({
|
||||
config,
|
||||
views,
|
||||
streams,
|
||||
}: Pick<StreamwallState, 'config' | 'views' | 'streams'>) {
|
||||
const { width, height, activeColor } = config
|
||||
const activeViews = views.filter(
|
||||
({ state }) =>
|
||||
matchesState('displaying', state) &&
|
||||
!matchesState('displaying.error', state),
|
||||
)
|
||||
const overlays = streams.filter((s) => s.kind === 'overlay')
|
||||
return (
|
||||
<div>
|
||||
{activeViews.map(({ state, context }) => {
|
||||
const { content, pos } = context
|
||||
if (!content) {
|
||||
return
|
||||
}
|
||||
|
||||
const data = streams.find((d) => content.url === d.link)
|
||||
const isListening = matchesState(
|
||||
'displaying.running.audio.listening',
|
||||
state,
|
||||
)
|
||||
const isBackgroundListening = matchesState(
|
||||
'displaying.running.audio.background',
|
||||
state,
|
||||
)
|
||||
const isBlurred = matchesState(
|
||||
'displaying.running.video.blurred',
|
||||
state,
|
||||
)
|
||||
const isLoading = matchesState('displaying.loading', state)
|
||||
const hasTitle = data && (data.label || data.source)
|
||||
const position = data?.labelPosition ?? 'top-left'
|
||||
return (
|
||||
<SpaceBorder
|
||||
pos={pos}
|
||||
windowWidth={width}
|
||||
windowHeight={height}
|
||||
activeColor={activeColor}
|
||||
isListening={isListening}
|
||||
>
|
||||
<BlurCover isBlurred={isBlurred} />
|
||||
{hasTitle && (
|
||||
<StreamTitle
|
||||
position={position}
|
||||
activeColor={activeColor}
|
||||
isListening={isListening}
|
||||
>
|
||||
<StreamIcon url={content.url} />
|
||||
<span>{data.label}</span>
|
||||
{(isListening || isBackgroundListening) && <FaVolumeUp />}
|
||||
</StreamTitle>
|
||||
)}
|
||||
{isLoading && <LoadingSpinner />}
|
||||
</SpaceBorder>
|
||||
)
|
||||
})}
|
||||
{overlays.map((s) => (
|
||||
<OverlayIFrame
|
||||
key={s._id}
|
||||
src={s.link}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
allow="autoplay"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [state, setState] = useState<StreamwallState | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = window.streamwallLayer.onState(setState)
|
||||
window.streamwallLayer.load()
|
||||
return unsubscribe
|
||||
}, [])
|
||||
|
||||
useHotkeys('ctrl+shift+i', () => {
|
||||
window.streamwallLayer.openDevTools()
|
||||
})
|
||||
|
||||
if (!state) {
|
||||
return
|
||||
}
|
||||
|
||||
const { config, views, streams } = state
|
||||
return <Overlay config={config} views={views} streams={streams} />
|
||||
}
|
||||
|
||||
function StreamIcon({ url }: { url: string }) {
|
||||
let parsedURL
|
||||
try {
|
||||
parsedURL = new URL(url)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
let { host } = parsedURL
|
||||
host = host.replace(/^www\./, '')
|
||||
if (host === 'youtube.com' || host === 'youtu.be') {
|
||||
return <FaYoutube />
|
||||
} else if (host === 'facebook.com' || host === 'm.facebook.com') {
|
||||
return <FaFacebook />
|
||||
} else if (host === 'twitch.tv') {
|
||||
return <FaTwitch />
|
||||
} else if (host === 'instagram.com') {
|
||||
return <FaInstagram />
|
||||
} else if (host === 'tiktok.com') {
|
||||
return <FaTiktok />
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const SpaceBorder = styled.div.attrs(() => ({
|
||||
borderWidth: 2,
|
||||
}))`
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
position: fixed;
|
||||
left: ${({ pos }) => pos.x}px;
|
||||
top: ${({ pos }) => pos.y}px;
|
||||
width: ${({ pos }) => pos.width}px;
|
||||
height: ${({ pos }) => pos.height}px;
|
||||
border: 0 solid black;
|
||||
border-left-width: ${({ pos, borderWidth }) =>
|
||||
pos.x === 0 ? 0 : borderWidth}px;
|
||||
border-right-width: ${({ pos, borderWidth, windowWidth }) =>
|
||||
pos.x + pos.width === windowWidth ? 0 : borderWidth}px;
|
||||
border-top-width: ${({ pos, borderWidth }) =>
|
||||
pos.y === 0 ? 0 : borderWidth}px;
|
||||
border-bottom-width: ${({ pos, borderWidth, windowHeight }) =>
|
||||
pos.y + pos.height === windowHeight ? 0 : borderWidth}px;
|
||||
box-shadow: ${({ isListening, activeColor }) =>
|
||||
isListening ? `0 0 10px ${activeColor} inset` : 'none'};
|
||||
box-sizing: border-box;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
`
|
||||
|
||||
const StreamTitle = styled.div`
|
||||
position: absolute;
|
||||
${({ position }) => {
|
||||
if (position === 'top-left') {
|
||||
return `top: 0; left: 0;`
|
||||
} else if (position === 'top-right') {
|
||||
return `top: 0; right: 0;`
|
||||
} else if (position === 'bottom-right') {
|
||||
return `bottom: 0; right: 0;`
|
||||
} else if (position === 'bottom-left') {
|
||||
return `bottom: 0; left: 0;`
|
||||
}
|
||||
}}
|
||||
max-width: calc(100% - 10px);
|
||||
box-sizing: border-box;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px 10px;
|
||||
margin: 5px;
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
color: white;
|
||||
text-shadow: 0 0 4px black;
|
||||
letter-spacing: -0.025em;
|
||||
background: ${({
|
||||
isListening,
|
||||
activeColor,
|
||||
}: {
|
||||
isListening: boolean
|
||||
activeColor: string
|
||||
}) =>
|
||||
Color(isListening ? activeColor : 'black')
|
||||
.alpha(0.5)
|
||||
.toString()};
|
||||
border-radius: 4px;
|
||||
backdrop-filter: blur(10px);
|
||||
overflow: hidden;
|
||||
|
||||
span {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
overflow: visible;
|
||||
filter: drop-shadow(0 0 4px black);
|
||||
|
||||
&:first-child {
|
||||
margin-right: 0.35em;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
|
||||
path {
|
||||
fill: white;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const LoadingSpinner = styled(TailSpin)`
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
opacity: 0.5;
|
||||
`
|
||||
|
||||
const BlurCover = styled.div`
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
backdrop-filter: ${({ isBlurred }) => (isBlurred ? 'blur(30px)' : 'blur(0)')};
|
||||
`
|
||||
|
||||
const OverlayIFrame = styled.iframe`
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
border: none;
|
||||
pointer-events: none;
|
||||
`
|
||||
|
||||
render(<App />, document.body)
|
||||
14
packages/streamwall/src/renderer/playHLS.html
Normal file
14
packages/streamwall/src/renderer/playHLS.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Streamwall HLS Player</title>
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; connect-src *; media-src * blob:"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<script src="playHLS.ts" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
19
packages/streamwall/src/renderer/playHLS.ts
Normal file
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
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>>
|
||||
}
|
||||
6
packages/streamwall/src/util.ts
Normal file
6
packages/streamwall/src/util.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function ensureValidURL(urlStr: string) {
|
||||
const url = new URL(urlStr)
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||
throw new Error(`rejecting attempt to load non-http URL '${urlStr}'`)
|
||||
}
|
||||
}
|
||||
23
packages/streamwall/tsconfig.json
Normal file
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
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
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
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[]),
|
||||
],
|
||||
})
|
||||
Reference in New Issue
Block a user