Fix view reuse bugs and create/destroy views on demand

This commit is contained in:
Max Goodhart
2020-06-20 15:44:10 -07:00
parent 0d7cd9cbbe
commit 6b38d19294
2 changed files with 84 additions and 73 deletions

View File

@@ -1,3 +1,4 @@
import intersection from 'lodash/intersection'
import EventEmitter from 'events' import EventEmitter from 'events'
import { BrowserView, BrowserWindow, ipcMain } from 'electron' import { BrowserView, BrowserWindow, ipcMain } from 'electron'
import { interpret } from 'xstate' import { interpret } from 'xstate'
@@ -17,9 +18,10 @@ export default class StreamWindow extends EventEmitter {
constructor() { constructor() {
super() super()
this.win = null this.win = null
this.offscreenWin = null
this.overlayView = null this.overlayView = null
this.views = [] this.views = []
this.viewStates = new Map() this.viewActions = null
} }
init() { init() {
@@ -41,6 +43,14 @@ export default class StreamWindow extends EventEmitter {
}) })
this.win = win this.win = win
const offscreenWin = new BrowserWindow({
show: false,
webPreferences: {
offscreen: true,
},
})
this.offscreenWin = offscreenWin
const overlayView = new BrowserView({ const overlayView = new BrowserView({
webPreferences: { webPreferences: {
nodeIntegration: true, nodeIntegration: true,
@@ -56,10 +66,12 @@ export default class StreamWindow extends EventEmitter {
overlayView.webContents.loadFile('overlay.html') overlayView.webContents.loadFile('overlay.html')
this.overlayView = overlayView this.overlayView = overlayView
const actions = { this.viewActions = {
hideView: (context, event) => { offscreenView: (context, event) => {
const { view } = context const { view } = context
win.removeBrowserView(view) // 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 the Page Visibility API being hidden.
offscreenWin.addBrowserView(view)
view.setBounds({ x: 0, y: 0, width: SPACE_WIDTH, height: SPACE_HEIGHT })
}, },
positionView: (context, event) => { positionView: (context, event) => {
const { pos, view } = context const { pos, view } = context
@@ -73,8 +85,13 @@ export default class StreamWindow extends EventEmitter {
}, },
} }
const views = [] ipcMain.on('devtools-overlay', () => {
for (let idx = 0; idx <= 9; idx++) { overlayView.webContents.openDevTools()
})
}
createView() {
const { win, overlayView, viewActions } = this
const view = new BrowserView({ const view = new BrowserView({
webPreferences: { partition: 'persist:session', sandbox: true }, webPreferences: { partition: 'persist:session', sandbox: true },
}) })
@@ -87,67 +104,73 @@ export default class StreamWindow extends EventEmitter {
parentWin: win, parentWin: win,
overlayView, overlayView,
}) })
.withConfig({ actions }) .withConfig({ actions: viewActions })
const service = interpret(machine).start() const service = interpret(machine).start()
service.onTransition((state) => this.handleViewTransition(idx, state)) service.onTransition(this.emitState.bind(this))
views.push(service) return service
}
this.views = views
ipcMain.on('devtools-overlay', () => {
overlayView.webContents.openDevTools()
})
} }
handleViewTransition(idx, state) { emitState() {
const viewState = { this.emit(
'state',
this.views.map(({ state }) => ({
state: state.value, state: state.value,
context: { context: {
url: state.context.url, url: state.context.url,
info: state.context.info, info: state.context.info,
pos: state.context.pos, pos: state.context.pos,
}, },
} })),
this.viewStates.set(idx, viewState) )
this.emit('state', [...this.viewStates.values()])
} }
setViews(viewURLMap) { setViews(viewURLMap) {
const { views } = this const { win, views } = this
const boxes = boxesFromViewURLMap(GRID_COUNT, GRID_COUNT, viewURLMap) const boxes = boxesFromViewURLMap(GRID_COUNT, GRID_COUNT, viewURLMap)
const remainingBoxes = new Set(boxes.filter(({ url }) => url)) const remainingBoxes = new Set(boxes.filter(({ url }) => url))
const unusedViews = new Set(views) const unusedViews = new Set(views)
const viewsToDisplay = [] const viewsToDisplay = []
// We try to find the best match for moving / reusing existing views to match the new positions.
const matchers = [ const matchers = [
// First try to find a loaded view of the same URL... // First try to find a loaded view of the same URL in the same space...
(v, url) => (v, url, spaces) =>
unusedViews.has(v) &&
v.state.context.url === url && v.state.context.url === url &&
v.state.matches('displaying.running'), 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'),
// Then try view with the same URL that is still loading... // Then try view with the same URL that is still loading...
(v, url) => unusedViews.has(v) && v.state.context.url === url, (v, url) => v.state.context.url === url,
// If none could be found, try an unused view.
(v) => unusedViews.has(v),
() => {
throw new Error('could not find a usable view')
},
] ]
for (const matcher of matchers) { for (const matcher of matchers) {
for (const box of remainingBoxes) { for (const box of remainingBoxes) {
const { url } = box const { url, spaces } = box
const view = views.find((v) => matcher(v, url)) let foundView
if (view) { for (const view of unusedViews) {
viewsToDisplay.push({ box, view }) if (matcher(view, url, spaces)) {
unusedViews.delete(view) foundView = view
break
}
}
if (foundView) {
viewsToDisplay.push({ box, view: foundView })
unusedViews.delete(foundView)
remainingBoxes.delete(box) remainingBoxes.delete(box)
} }
} }
} }
for (const box of remainingBoxes) {
const view = this.createView()
viewsToDisplay.push({ box, view })
}
const newViews = []
for (const { box, view } of viewsToDisplay) { for (const { box, view } of viewsToDisplay) {
const { url, x, y, w, h, spaces } = box const { url, x, y, w, h, spaces } = box
const pos = { const pos = {
@@ -158,11 +181,15 @@ export default class StreamWindow extends EventEmitter {
spaces, spaces,
} }
view.send({ type: 'DISPLAY', pos, url }) view.send({ type: 'DISPLAY', pos, url })
newViews.push(view)
} }
for (const view of unusedViews) { for (const view of unusedViews) {
view.send('CLEAR') const browserView = view.state.context.view
win.removeBrowserView(browserView)
browserView.destroy()
} }
this.views = newViews
this.emitState()
} }
setListeningView(viewIdx) { setListeningView(viewIdx) {

View File

@@ -11,23 +11,10 @@ const viewStateMachine = Machine(
info: {}, info: {},
}, },
on: { on: {
CLEAR: 'empty',
DISPLAY: 'displaying', DISPLAY: 'displaying',
}, },
states: { states: {
empty: { empty: {},
entry: assign({
pos: {},
info: {},
url: null,
}),
invoke: {
src: 'clearView',
onError: {
target: '#view.displaying.error',
},
},
},
displaying: { displaying: {
id: 'displaying', id: 'displaying',
initial: 'loading', initial: 'loading',
@@ -47,6 +34,7 @@ const viewStateMachine = Machine(
states: { states: {
loading: { loading: {
initial: 'page', initial: 'page',
entry: 'offscreenView',
states: { states: {
page: { page: {
invoke: { invoke: {
@@ -78,7 +66,6 @@ const viewStateMachine = Machine(
running: { running: {
initial: 'muted', initial: 'muted',
entry: 'positionView', entry: 'positionView',
exit: 'hideView',
on: { on: {
DISPLAY: { DISPLAY: {
actions: [ actions: [
@@ -126,9 +113,6 @@ const viewStateMachine = Machine(
}, },
}, },
services: { services: {
clearView: async (context, event) => {
await context.view.webContents.loadURL('about:blank')
},
loadURL: async (context, event) => { loadURL: async (context, event) => {
const { url, view } = context const { url, view } = context
const wc = view.webContents const wc = view.webContents