Initial v2 overhaul

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

View File

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

View File

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

View File

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

View File

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

View File

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