mirror of
https://github.com/streamwall/streamwall.git
synced 2026-01-27 15:32:48 -05:00
Add support for displaying custom web content in views
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import isEqual from 'lodash/isEqual'
|
||||
import intersection from 'lodash/intersection'
|
||||
import EventEmitter from 'events'
|
||||
import { BrowserView, BrowserWindow, ipcMain } from 'electron'
|
||||
import { interpret } from 'xstate'
|
||||
|
||||
import viewStateMachine from './viewStateMachine'
|
||||
import { boxesFromViewURLMap } from './geometry'
|
||||
import { boxesFromViewContentMap } from './geometry'
|
||||
|
||||
import {
|
||||
WIDTH,
|
||||
@@ -118,7 +119,7 @@ export default class StreamWindow extends EventEmitter {
|
||||
this.views.map(({ state }) => ({
|
||||
state: state.value,
|
||||
context: {
|
||||
url: state.context.url,
|
||||
content: state.context.content,
|
||||
info: state.context.info,
|
||||
pos: state.context.pos,
|
||||
},
|
||||
@@ -126,34 +127,38 @@ export default class StreamWindow extends EventEmitter {
|
||||
)
|
||||
}
|
||||
|
||||
setViews(viewURLMap) {
|
||||
setViews(viewContentMap) {
|
||||
const { win, views } = this
|
||||
const boxes = boxesFromViewURLMap(GRID_COUNT, GRID_COUNT, viewURLMap)
|
||||
const remainingBoxes = new Set(boxes.filter(({ url }) => url))
|
||||
|
||||
const boxes = boxesFromViewContentMap(
|
||||
GRID_COUNT,
|
||||
GRID_COUNT,
|
||||
viewContentMap,
|
||||
)
|
||||
const remainingBoxes = new Set(boxes)
|
||||
const unusedViews = new Set(views)
|
||||
const viewsToDisplay = []
|
||||
|
||||
// We try to find the best match for moving / reusing existing views to match the new positions.
|
||||
const matchers = [
|
||||
// First try to find a loaded view of the same URL in the same space...
|
||||
(v, url, spaces) =>
|
||||
v.state.context.url === url &&
|
||||
(v, content, spaces) =>
|
||||
isEqual(v.state.context.content, content) &&
|
||||
v.state.matches('displaying.running') &&
|
||||
intersection(v.state.context.pos.spaces, spaces).length > 0,
|
||||
// Then try to find a loaded view of the same URL...
|
||||
(v, url) =>
|
||||
v.state.context.url === url && v.state.matches('displaying.running'),
|
||||
(v, content) =>
|
||||
isEqual(v.state.context.content, content) &&
|
||||
v.state.matches('displaying.running'),
|
||||
// Then try view with the same URL that is still loading...
|
||||
(v, url) => v.state.context.url === url,
|
||||
(v, content) => isEqual(v.state.context.content, content),
|
||||
]
|
||||
|
||||
for (const matcher of matchers) {
|
||||
for (const box of remainingBoxes) {
|
||||
const { url, spaces } = box
|
||||
const { content, spaces } = box
|
||||
let foundView
|
||||
for (const view of unusedViews) {
|
||||
if (matcher(view, url, spaces)) {
|
||||
if (matcher(view, content, spaces)) {
|
||||
foundView = view
|
||||
break
|
||||
}
|
||||
@@ -173,7 +178,7 @@ export default class StreamWindow extends EventEmitter {
|
||||
|
||||
const newViews = []
|
||||
for (const { box, view } of viewsToDisplay) {
|
||||
const { url, x, y, w, h, spaces } = box
|
||||
const { content, x, y, w, h, spaces } = box
|
||||
const pos = {
|
||||
x: SPACE_WIDTH * x,
|
||||
y: SPACE_HEIGHT * y,
|
||||
@@ -181,7 +186,7 @@ export default class StreamWindow extends EventEmitter {
|
||||
height: SPACE_HEIGHT * h,
|
||||
spaces,
|
||||
}
|
||||
view.send({ type: 'DISPLAY', pos, url })
|
||||
view.send({ type: 'DISPLAY', pos, content })
|
||||
newViews.push(view)
|
||||
}
|
||||
for (const view of unusedViews) {
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
export function boxesFromViewURLMap(width, height, stateURLMap) {
|
||||
import isEqual from 'lodash/isEqual'
|
||||
|
||||
export function boxesFromViewContentMap(width, height, viewContentMap) {
|
||||
const boxes = []
|
||||
const visited = new Set()
|
||||
|
||||
function isPosContent(x, y, content) {
|
||||
const checkIdx = width * y + x
|
||||
return (
|
||||
!visited.has(checkIdx) && isEqual(viewContentMap.get(checkIdx), content)
|
||||
)
|
||||
}
|
||||
|
||||
function findLargestBox(x, y) {
|
||||
const idx = width * y + x
|
||||
const spaces = [idx]
|
||||
const url = stateURLMap.get(idx)
|
||||
const content = viewContentMap.get(idx)
|
||||
|
||||
let maxY
|
||||
for (maxY = y + 1; maxY < height; maxY++) {
|
||||
const checkIdx = width * maxY + x
|
||||
if (visited.has(checkIdx) || stateURLMap.get(checkIdx) !== url) {
|
||||
if (!isPosContent(x, maxY, content)) {
|
||||
break
|
||||
}
|
||||
spaces.push(width * maxY + x)
|
||||
@@ -20,8 +28,7 @@ export function boxesFromViewURLMap(width, height, stateURLMap) {
|
||||
let cy = y
|
||||
scan: for (cx = x + 1; cx < width; cx++) {
|
||||
for (cy = y; cy < maxY; cy++) {
|
||||
const checkIdx = width * cy + cx
|
||||
if (visited.has(checkIdx) || stateURLMap.get(checkIdx) !== url) {
|
||||
if (!isPosContent(cx, cy, content)) {
|
||||
break scan
|
||||
}
|
||||
}
|
||||
@@ -32,13 +39,13 @@ export function boxesFromViewURLMap(width, height, stateURLMap) {
|
||||
const w = cx - x
|
||||
const h = maxY - y
|
||||
spaces.sort()
|
||||
return { url, x, y, w, h, spaces }
|
||||
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) || stateURLMap.get(idx) === undefined) {
|
||||
if (visited.has(idx) || viewContentMap.get(idx) === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { boxesFromViewURLMap } from './geometry'
|
||||
import { boxesFromViewContentMap } from './geometry'
|
||||
|
||||
function example([text]) {
|
||||
return text
|
||||
.replace(/\s/g, '')
|
||||
.split('')
|
||||
.map((c) => (c === '.' ? undefined : c))
|
||||
.map((c) => (c === '.' ? undefined : { url: c }))
|
||||
}
|
||||
|
||||
const box1 = example`
|
||||
@@ -41,8 +41,8 @@ describe.each([
|
||||
2,
|
||||
box1,
|
||||
[
|
||||
{ url: 'a', x: 0, y: 0, w: 1, h: 2, spaces: [0, 2] },
|
||||
{ url: 'b', x: 1, y: 0, w: 1, h: 2, spaces: [1, 3] },
|
||||
{ content: { url: 'a' }, x: 0, y: 0, w: 1, h: 2, spaces: [0, 2] },
|
||||
{ content: { url: 'b' }, x: 1, y: 0, w: 1, h: 2, spaces: [1, 3] },
|
||||
],
|
||||
],
|
||||
[
|
||||
@@ -50,8 +50,8 @@ describe.each([
|
||||
2,
|
||||
box2,
|
||||
[
|
||||
{ url: 'a', x: 0, y: 0, w: 2, h: 1, spaces: [0, 1] },
|
||||
{ url: 'b', x: 0, y: 1, w: 2, h: 1, spaces: [2, 3] },
|
||||
{ content: { url: 'a' }, x: 0, y: 0, w: 2, h: 1, spaces: [0, 1] },
|
||||
{ content: { url: 'b' }, x: 0, y: 1, w: 2, h: 1, spaces: [2, 3] },
|
||||
],
|
||||
],
|
||||
[
|
||||
@@ -59,28 +59,33 @@ describe.each([
|
||||
3,
|
||||
box3,
|
||||
[
|
||||
{ url: 'a', x: 0, y: 0, w: 2, h: 2, spaces: [0, 1, 3, 4] },
|
||||
{ url: 'c', x: 2, y: 0, w: 1, h: 1, spaces: [2] },
|
||||
{ url: 'a', x: 2, y: 1, w: 1, h: 1, spaces: [5] },
|
||||
{ url: 'd', x: 0, y: 2, w: 1, h: 1, spaces: [6] },
|
||||
{ url: 'a', x: 1, y: 2, w: 1, h: 1, spaces: [7] },
|
||||
{ url: 'e', x: 2, y: 2, w: 1, h: 1, spaces: [8] },
|
||||
{ content: { url: 'a' }, x: 0, y: 0, w: 2, h: 2, spaces: [0, 1, 3, 4] },
|
||||
{ content: { url: 'c' }, x: 2, y: 0, w: 1, h: 1, spaces: [2] },
|
||||
{ content: { url: 'a' }, x: 2, y: 1, w: 1, h: 1, spaces: [5] },
|
||||
{ content: { url: 'd' }, x: 0, y: 2, w: 1, h: 1, spaces: [6] },
|
||||
{ content: { url: 'a' }, x: 1, y: 2, w: 1, h: 1, spaces: [7] },
|
||||
{ content: { url: 'e' }, x: 2, y: 2, w: 1, h: 1, spaces: [8] },
|
||||
],
|
||||
],
|
||||
[3, 3, box4, [{ url: 'a', x: 1, y: 1, w: 2, h: 2, spaces: [4, 5, 7, 8] }]],
|
||||
[
|
||||
3,
|
||||
3,
|
||||
box4,
|
||||
[{ content: { url: 'a' }, x: 1, y: 1, w: 2, h: 2, spaces: [4, 5, 7, 8] }],
|
||||
],
|
||||
[
|
||||
3,
|
||||
3,
|
||||
box5,
|
||||
[
|
||||
{ url: 'a', x: 2, y: 0, w: 1, h: 3, spaces: [2, 5, 8] },
|
||||
{ url: 'a', x: 1, y: 2, w: 1, h: 1, spaces: [7] },
|
||||
{ content: { url: 'a' }, x: 2, y: 0, w: 1, h: 3, spaces: [2, 5, 8] },
|
||||
{ content: { url: 'a' }, x: 1, y: 2, w: 1, h: 1, spaces: [7] },
|
||||
],
|
||||
],
|
||||
])('boxesFromViewURLMap(%i, %i, %j)', (width, height, data, expected) => {
|
||||
])('boxesFromViewContentMap(%i, %i, %j)', (width, height, data, expected) => {
|
||||
test(`returns expected ${expected.length} boxes`, () => {
|
||||
const stateURLMap = new Map(data.map((v, idx) => [idx, v]))
|
||||
const result = boxesFromViewURLMap(width, height, stateURLMap)
|
||||
const result = boxesFromViewContentMap(width, height, stateURLMap)
|
||||
expect(result).toStrictEqual(expected)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import isEqual from 'lodash/isEqual'
|
||||
import { Machine, assign } from 'xstate'
|
||||
|
||||
const viewStateMachine = Machine(
|
||||
@@ -7,7 +8,7 @@ const viewStateMachine = Machine(
|
||||
context: {
|
||||
view: null,
|
||||
pos: null,
|
||||
url: null,
|
||||
content: null,
|
||||
info: {},
|
||||
},
|
||||
on: {
|
||||
@@ -20,14 +21,14 @@ const viewStateMachine = Machine(
|
||||
initial: 'loading',
|
||||
entry: assign({
|
||||
pos: (context, event) => event.pos,
|
||||
url: (context, event) => event.url,
|
||||
content: (context, event) => event.content,
|
||||
}),
|
||||
on: {
|
||||
DISPLAY: {
|
||||
actions: assign({
|
||||
pos: (context, event) => event.pos,
|
||||
}),
|
||||
cond: 'urlUnchanged',
|
||||
cond: 'contentUnchanged',
|
||||
},
|
||||
RELOAD: '.loading',
|
||||
},
|
||||
@@ -38,7 +39,7 @@ const viewStateMachine = Machine(
|
||||
states: {
|
||||
page: {
|
||||
invoke: {
|
||||
src: 'loadURL',
|
||||
src: 'loadPage',
|
||||
onDone: {
|
||||
target: 'video',
|
||||
},
|
||||
@@ -74,7 +75,7 @@ const viewStateMachine = Machine(
|
||||
}),
|
||||
'positionView',
|
||||
],
|
||||
cond: 'urlUnchanged',
|
||||
cond: 'contentUnchanged',
|
||||
},
|
||||
MUTE: '.muted',
|
||||
UNMUTE: '.listening',
|
||||
@@ -108,18 +109,19 @@ const viewStateMachine = Machine(
|
||||
},
|
||||
},
|
||||
guards: {
|
||||
urlUnchanged: (context, event) => {
|
||||
return context.url === event.url
|
||||
contentUnchanged: (context, event) => {
|
||||
return isEqual(context.content, event.content)
|
||||
},
|
||||
},
|
||||
services: {
|
||||
loadURL: async (context, event) => {
|
||||
const { url, view } = context
|
||||
loadPage: async (context, event) => {
|
||||
const { content, view } = context
|
||||
const wc = view.webContents
|
||||
wc.audioMuted = true
|
||||
await wc.loadURL(url)
|
||||
wc.insertCSS(
|
||||
`
|
||||
await wc.loadURL(content.url)
|
||||
if (content.kind === 'video') {
|
||||
wc.insertCSS(
|
||||
`
|
||||
* {
|
||||
display: none !important;
|
||||
pointer-events: none;
|
||||
@@ -145,11 +147,16 @@ const viewStateMachine = Machine(
|
||||
z-index: 999999 !important;
|
||||
}
|
||||
`,
|
||||
{ cssOrigin: 'user' },
|
||||
)
|
||||
{ cssOrigin: 'user' },
|
||||
)
|
||||
}
|
||||
},
|
||||
startVideo: async (context, event) => {
|
||||
const wc = context.view.webContents
|
||||
const { content, view } = context
|
||||
if (content.kind !== 'video') {
|
||||
return
|
||||
}
|
||||
const wc = view.webContents
|
||||
const info = await wc.executeJavaScript(`
|
||||
const sleep = ms => new Promise((resolve) => setTimeout(resolve, ms))
|
||||
async function waitForVideo() {
|
||||
|
||||
Reference in New Issue
Block a user