mirror of
https://github.com/streamwall/streamwall.git
synced 2026-01-29 16:32:48 -05:00
Initial v2 overhaul
This commit is contained in:
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"]
|
||||
}
|
||||
Reference in New Issue
Block a user