diff --git a/src/browser/overlay.js b/src/browser/overlay.js index df1dd8c..385911f 100644 --- a/src/browser/overlay.js +++ b/src/browser/overlay.js @@ -27,15 +27,17 @@ function Overlay({ views, streams, customStreams }) { return (
{activeViews.map((viewState) => { - const { url, pos } = viewState.context - const data = [...streams, ...customStreams].find((d) => url === d.Link) + const { content, pos } = viewState.context + const data = [...streams, ...customStreams].find( + (d) => content.url === d.Link, + ) const isListening = viewState.matches('displaying.running.listening') const isLoading = viewState.matches('displaying.loading') return ( {data && ( - + {data.hasOwnProperty('Label') ? ( data.Label diff --git a/src/node/StreamWindow.js b/src/node/StreamWindow.js index 3594a57..09d0e7a 100644 --- a/src/node/StreamWindow.js +++ b/src/node/StreamWindow.js @@ -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) { diff --git a/src/node/geometry.js b/src/node/geometry.js index 78956b7..b4081b6 100644 --- a/src/node/geometry.js +++ b/src/node/geometry.js @@ -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 } diff --git a/src/node/geometry.test.js b/src/node/geometry.test.js index 42f9ae3..360651b 100644 --- a/src/node/geometry.test.js +++ b/src/node/geometry.test.js @@ -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) }) }) diff --git a/src/node/viewStateMachine.js b/src/node/viewStateMachine.js index a039829..10678bc 100644 --- a/src/node/viewStateMachine.js +++ b/src/node/viewStateMachine.js @@ -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() { diff --git a/src/web/control.js b/src/web/control.js index c3f700e..5f4ea77 100644 --- a/src/web/control.js +++ b/src/web/control.js @@ -11,26 +11,12 @@ import SoundIcon from '../static/volume-up-solid.svg' import ReloadIcon from '../static/redo-alt-solid.svg' import LifeRingIcon from '../static/life-ring-regular.svg' -function emptyStateIdxMap() { - return new Map( - range(GRID_COUNT * GRID_COUNT).map((idx) => [ - idx, - { - streamId: null, - url: null, - state: State.from({}), - isListening: false, - }, - ]), - ) -} - function App({ wsEndpoint }) { const wsRef = useRef() const [isConnected, setIsConnected] = useState(false) const [streams, setStreams] = useState([]) const [customStreams, setCustomStreams] = useState([]) - const [stateIdxMap, setStateIdxMap] = useState(emptyStateIdxMap()) + const [stateIdxMap, setStateIdxMap] = useState(new Map()) useEffect(() => { const ws = new ReconnectingWebSocket(wsEndpoint, [], { @@ -48,20 +34,21 @@ function App({ wsEndpoint }) { views, customStreams: newCustomStreams, } = msg.state - const newStateIdxMap = emptyStateIdxMap() + const newStateIdxMap = new Map() const allStreams = [...newStreams, ...newCustomStreams] for (const viewState of views) { - const { pos, url } = viewState.context - if (!url) { - continue - } - const streamId = allStreams.find((d) => d.Link === url)?._id + const { pos, content } = viewState.context + const stream = allStreams.find((d) => d.Link === content.url) + const streamId = stream?._id const state = State.from(viewState.state) const isListening = state.matches('displaying.running.listening') for (const space of pos.spaces) { + if (!newStateIdxMap.has(space)) { + newStateIdxMap.set(space, {}) + } Object.assign(newStateIdxMap.get(space), { streamId, - url, + content, state, isListening, }) @@ -80,24 +67,25 @@ function App({ wsEndpoint }) { const handleSetView = useCallback( (idx, streamId) => { const newSpaceIdxMap = new Map(stateIdxMap) - const url = [...streams, ...customStreams].find((d) => d._id === streamId) - ?.Link - if (url) { + const stream = [...streams, ...customStreams].find( + (d) => d._id === streamId, + ) + if (stream) { + const content = { + url: stream?.Link, + kind: stream?.Kind || 'video', + } newSpaceIdxMap.set(idx, { ...newSpaceIdxMap.get(idx), streamId, - url, + content, }) } else { - newSpaceIdxMap.set(idx, { - ...newSpaceIdxMap.get(idx), - streamId: null, - url: null, - }) + newSpaceIdxMap.delete(idx) } - const views = Array.from(newSpaceIdxMap, ([space, { url }]) => [ + const views = Array.from(newSpaceIdxMap, ([space, { content }]) => [ space, - url, + content, ]) wsRef.current.send(JSON.stringify({ type: 'set-views', views })) }, @@ -155,18 +143,21 @@ function App({ wsEndpoint }) { {range(0, 3).map((x) => { const idx = 3 * y + x - const { streamId, isListening, url, state } = stateIdxMap.get( - idx, - ) + const { + streamId = '', + isListening = false, + content = { url: '' }, + state, + } = stateIdxMap.get(idx) || {} return ( ( + {[...customStreams, { Link: '', Label: '', Kind: 'video' }].map( + ({ Link, Label, Kind }, idx) => ( ), @@ -311,6 +303,12 @@ function CustomStreamInput({ idx, onChange, ...props }) { }, [onChange], ) + const handleChangeKind = useCallback( + (ev) => { + onChange(idx, { ...props, Kind: ev.target.value }) + }, + [onChange], + ) return (
+
) }