Implement stream rotation, overhaul local data, add preload script

This moves a bunch of the architectural improvements from the 'iframe'
branch in to main in the pursuit of implementing stream rotation driven
by a stream data field.
This commit is contained in:
Max Goodhart
2020-11-08 13:07:48 -08:00
parent f591685a36
commit c219656564
9 changed files with 492 additions and 272 deletions

View File

@@ -8,6 +8,14 @@ import { interpret } from 'xstate'
import viewStateMachine from './viewStateMachine'
import { boxesFromViewContentMap } from '../geometry'
function getDisplayOptions(stream) {
if (!stream) {
return {}
}
const { rotation } = stream
return { rotation }
}
export default class StreamWindow extends EventEmitter {
constructor(config) {
super()
@@ -21,7 +29,8 @@ export default class StreamWindow extends EventEmitter {
this.offscreenWin = null
this.backgroundView = null
this.overlayView = null
this.views = []
this.views = new Map()
this.viewsByURL = new Map()
this.viewActions = null
}
@@ -119,6 +128,25 @@ export default class StreamWindow extends EventEmitter {
},
}
ipcMain.handle('view-init', async (ev) => {
const view = this.views.get(ev.sender.id)
if (view) {
view.send({ type: 'VIEW_INIT' })
return {
content: view.state.context.content,
options: view.state.context.options,
}
}
})
ipcMain.on('view-loaded', (ev) => {
this.views.get(ev.sender.id)?.send?.({ type: 'VIEW_LOADED' })
})
ipcMain.on('view-info', (ev, { info }) => {
this.views.get(ev.sender.id)?.send?.({ type: 'VIEW_INFO', info })
})
ipcMain.on('view-error', (ev, { err }) => {
this.views.get(ev.sender.id)?.send?.({ type: 'VIEW_ERROR', err })
})
ipcMain.on('devtools-overlay', () => {
overlayView.webContents.openDevTools()
})
@@ -129,6 +157,7 @@ export default class StreamWindow extends EventEmitter {
const { backgroundColor } = this.config
const view = new BrowserView({
webPreferences: {
preload: path.join(app.getAppPath(), 'mediaPreload.js'),
nodeIntegration: false,
enableRemoteModule: false,
contextIsolation: true,
@@ -137,6 +166,8 @@ export default class StreamWindow extends EventEmitter {
})
view.setBackgroundColor(backgroundColor)
const viewId = view.webContents.id
// Prevent view pages from navigating away from the specified URL.
view.webContents.on('will-navigate', (ev) => {
ev.preventDefault()
@@ -145,37 +176,42 @@ export default class StreamWindow extends EventEmitter {
const machine = viewStateMachine
.withContext({
...viewStateMachine.context,
id: viewId,
view,
parentWin: win,
overlayView,
})
.withConfig({ actions: viewActions })
const service = interpret(machine).start()
service.onTransition(this.emitState.bind(this))
service.onTransition((state) => {
if (!state.changed) {
return
}
this.emitState(state)
})
return service
}
emitState() {
this.emit(
'state',
this.views.map(({ state }) => ({
state: state.value,
context: {
content: state.context.content,
info: state.context.info,
pos: state.context.pos,
},
})),
)
const states = Array.from(this.views.values(), ({ state }) => ({
state: state.value,
context: {
id: state.context.id,
content: state.context.content,
info: state.context.info,
pos: state.context.pos,
},
}))
this.emit('state', states)
}
setViews(viewContentMap) {
setViews(viewContentMap, streams) {
const { gridCount, spaceWidth, spaceHeight } = this.config
const { win, views } = this
const boxes = boxesFromViewContentMap(gridCount, gridCount, viewContentMap)
const remainingBoxes = new Set(boxes)
const unusedViews = new Set(views)
const unusedViews = new Set(views.values())
const viewsToDisplay = []
// We try to find the best match for moving / reusing existing views to match the new positions.
@@ -216,7 +252,8 @@ export default class StreamWindow extends EventEmitter {
viewsToDisplay.push({ box, view })
}
const newViews = []
const newViews = new Map()
const newViewsByURL = new Map()
for (const { box, view } of viewsToDisplay) {
const { content, x, y, w, h, spaces } = box
const pos = {
@@ -226,8 +263,11 @@ export default class StreamWindow extends EventEmitter {
height: spaceHeight * h,
spaces,
}
const stream = streams.find((s) => s.url === content.url)
view.send({ type: 'OPTIONS', options: getDisplayOptions(stream) })
view.send({ type: 'DISPLAY', pos, content })
newViews.push(view)
newViews.set(view.state.context.id, view)
newViewsByURL.set(content.url, view)
}
for (const view of unusedViews) {
const browserView = view.state.context.view
@@ -235,12 +275,13 @@ export default class StreamWindow extends EventEmitter {
browserView.destroy()
}
this.views = newViews
this.viewsByURL = newViewsByURL
this.emitState()
}
setListeningView(viewIdx) {
const { views } = this
for (const view of views) {
for (const view of views.values()) {
if (!view.state.matches('displaying')) {
continue
}
@@ -251,10 +292,11 @@ export default class StreamWindow extends EventEmitter {
}
findViewByIdx(viewIdx) {
return this.views.find(
(v) =>
v.state.context.pos && v.state.context.pos.spaces.includes(viewIdx),
)
for (const view of this.views.values()) {
if (view.state.context.pos?.spaces?.includes?.(viewIdx)) {
return view
}
}
}
sendViewEvent(viewIdx, event) {
@@ -280,6 +322,17 @@ export default class StreamWindow extends EventEmitter {
this.sendViewEvent(viewIdx, { type: 'DEVTOOLS', inWebContents })
}
onState(state) {
this.send('state', state)
for (const stream of state.streams) {
const { link } = stream
this.viewsByURL.get(link)?.send?.({
type: 'OPTIONS',
options: getDisplayOptions(stream),
})
}
}
send(...args) {
this.overlayView.webContents.send(...args)
this.backgroundView.webContents.send(...args)

View File

@@ -1,4 +1,4 @@
import { once } from 'events'
import { EventEmitter, once } from 'events'
import { promises as fsPromises } from 'fs'
import { promisify } from 'util'
import { Repeater } from '@repeaterjs/repeater'
@@ -60,7 +60,51 @@ export async function* markDataSource(dataSource, name) {
export async function* combineDataSources(dataSources) {
for await (const streamLists of Repeater.latest(dataSources)) {
yield [].concat(...streamLists)
const dataByURL = new Map()
for (const list of streamLists) {
for (const data of list) {
const existing = dataByURL.get(data.link)
dataByURL.set(data.link, { ...existing, ...data })
}
}
yield [...dataByURL.values()]
}
}
export class LocalStreamData extends EventEmitter {
constructor() {
super()
this.dataByURL = new Map()
}
update(url, data) {
if (!data.link) {
data.link = url
}
const existing = this.dataByURL.get(url)
this.dataByURL.set(data.link, { ...existing, ...data })
if (url !== data.link) {
this.dataByURL.delete(url)
}
this._emitUpdate()
}
delete(url) {
this.dataByURL.delete(url)
this._emitUpdate()
}
_emitUpdate() {
this.emit('update', [...this.dataByURL.values()])
}
gen() {
return new Repeater(async (push, stop) => {
await push([])
this.on('update', push)
await stop
this.off('update', push)
})
}
}

View File

@@ -3,7 +3,6 @@ import path from 'path'
import yargs from 'yargs'
import TOML from '@iarna/toml'
import * as Y from 'yjs'
import { Repeater } from '@repeaterjs/repeater'
import * as Sentry from '@sentry/electron'
import { app, shell, session, BrowserWindow } from 'electron'
@@ -11,6 +10,7 @@ import { ensureValidURL } from '../util'
import {
pollDataURL,
watchDataFile,
LocalStreamData,
StreamIDGenerator,
markDataSource,
combineDataSources,
@@ -230,11 +230,8 @@ async function main() {
const persistData = await persistence.load()
const idGen = new StreamIDGenerator()
let updateCustomStreams
const customStreamData = new Repeater(async (push) => {
await push([])
updateCustomStreams = push
})
const localStreamData = new LocalStreamData()
const overlayStreamData = new LocalStreamData()
const streamWindow = new StreamWindow({
gridCount: argv.grid.count,
@@ -267,7 +264,6 @@ async function main() {
},
auth: auth.getState(),
streams: [],
customStreams: [],
views: [],
streamdelay: null,
})
@@ -282,20 +278,24 @@ async function main() {
}
})
viewsState.observeDeep(() => {
const viewContentMap = new Map()
for (const [key, viewData] of viewsState) {
const stream = clientState.info.streams.find(
(s) => s._id === viewData.get('streamId'),
)
if (!stream) {
continue
try {
const viewContentMap = new Map()
for (const [key, viewData] of viewsState) {
const stream = clientState.info.streams.find(
(s) => s._id === viewData.get('streamId'),
)
if (!stream) {
continue
}
viewContentMap.set(key, {
url: stream.link,
kind: stream.kind || 'video',
})
}
viewContentMap.set(key, {
url: stream.link,
kind: stream.kind || 'video',
})
streamWindow.setViews(viewContentMap, clientState.info.streams)
} catch (err) {
console.error('Error updating views', err)
}
streamWindow.setViews(viewContentMap)
})
const onMessage = async (msg, respond) => {
@@ -305,8 +305,14 @@ async function main() {
streamWindow.setViewBackgroundListening(msg.viewIdx, msg.listening)
} else if (msg.type === 'set-view-blurred') {
streamWindow.setViewBlurred(msg.viewIdx, msg.blurred)
} else if (msg.type === 'set-custom-streams') {
updateCustomStreams(msg.streams)
} else if (msg.type === 'rotate-stream') {
overlayStreamData.update(msg.url, {
rotation: msg.rotation,
})
} else if (msg.type === 'update-custom-stream') {
localStreamData.update(msg.url, msg.data)
} else if (msg.type === 'delete-custom-stream') {
localStreamData.delete(msg.url)
} else if (msg.type === 'reload-view') {
streamWindow.reloadView(msg.viewIdx)
} else if (msg.type === 'browse' || msg.type === 'dev-tools') {
@@ -353,7 +359,7 @@ async function main() {
function updateState(newState) {
clientState.update(newState)
streamWindow.send('state', clientState.info)
streamWindow.onState(clientState.info)
if (twitchBot) {
twitchBot.onState(clientState.info)
}
@@ -419,7 +425,8 @@ async function main() {
...argv.data['toml-file'].map((path) =>
markDataSource(watchDataFile(path), 'toml-file'),
),
markDataSource(customStreamData, 'custom'),
markDataSource(localStreamData.gen(), 'custom'),
overlayStreamData.gen(),
]
for await (const rawStreams of combineDataSources(dataSources)) {

View File

@@ -3,69 +3,16 @@ import { Machine, assign } from 'xstate'
import { ensureValidURL } from '../util'
const VIDEO_OVERRIDE_STYLE = `
* {
pointer-events: none;
display: none !important;
position: static !important;
z-index: 0 !important;
}
html, body, video, audio {
display: block !important;
background: black !important;
}
html, body {
overflow: hidden !important;
background: black !important;
}
video, iframe.__video__, audio {
display: block !important;
position: fixed !important;
left: 0 !important;
right: 0 !important;
top: 0 !important;
bottom: 0 !important;
width: 100vw !important;
height: 100vh !important;
object-fit: cover !important;
transition: none !important;
z-index: 999999 !important;
}
audio {
z-index: 999998 !important;
}
.__video_parent__ {
display: block !important;
}
video.__rot180__ {
transform: rotate(180deg) !important;
}
/* For 90 degree rotations, we position the video with swapped width and height and rotate it into place.
It's helpful to offset the video so the transformation is centered in the viewport center.
We move the video top left corner to center of the page and then translate half the video dimensions up and left.
Note that the width and height are swapped in the translate because the video starts with the side dimensions swapped. */
video.__rot90__ {
transform: translate(-50vh, -50vw) rotate(90deg) !important;
}
video.__rot270__ {
transform: translate(-50vh, -50vw) rotate(270deg) !important;
}
video.__rot90__, video.__rot270__ {
left: 50vw !important;
top: 50vh !important;
width: 100vh !important;
height: 100vw !important;
}
`
const viewStateMachine = Machine(
{
id: 'view',
initial: 'empty',
context: {
id: null,
view: null,
pos: null,
content: null,
options: null,
info: {},
},
on: {
@@ -87,39 +34,50 @@ const viewStateMachine = Machine(
}),
cond: 'contentUnchanged',
},
OPTIONS: {
actions: [
assign({
options: (context, event) => event.options,
}),
'sendViewOptions',
],
cond: 'optionsChanged',
},
RELOAD: '.loading',
DEVTOOLS: {
actions: 'openDevTools',
},
VIEW_ERROR: '.error',
VIEW_INFO: {
actions: assign({
info: (context, event) => event.info,
}),
},
},
states: {
loading: {
initial: 'page',
initial: 'navigate',
entry: 'offscreenView',
states: {
page: {
navigate: {
invoke: {
src: 'loadPage',
onDone: {
target: 'video',
target: 'waitForInit',
},
onError: {
target: '#view.displaying.error',
},
},
},
video: {
invoke: {
src: 'startVideo',
onDone: {
target: '#view.displaying.running',
actions: assign({
info: (context, event) => event.data,
}),
},
onError: {
target: '#view.displaying.error',
},
waitForInit: {
on: {
VIEW_INIT: 'waitForVideo',
},
},
waitForVideo: {
on: {
VIEW_LOADED: '#view.displaying.running',
},
},
},
@@ -200,11 +158,18 @@ const viewStateMachine = Machine(
view.webContents.setDevToolsWebContents(inWebContents)
view.webContents.openDevTools({ mode: 'detach' })
},
sendViewOptions: (context, event) => {
const { view } = context
view.webContents.send('options', event.options)
},
},
guards: {
contentUnchanged: (context, event) => {
return isEqual(context.content, event.content)
},
optionsChanged: (context, event) => {
return !isEqual(context.options, event.options)
},
},
services: {
loadPage: async (context, event) => {
@@ -212,149 +177,7 @@ const viewStateMachine = Machine(
ensureValidURL(content.url)
const wc = view.webContents
wc.audioMuted = true
await wc.loadURL(content.url)
if (content.kind === 'video' || content.kind === 'audio') {
wc.insertCSS(VIDEO_OVERRIDE_STYLE, { cssOrigin: 'user' })
} else if (content.kind === 'web') {
wc.insertCSS(
`
html, body {
overflow: hidden !important;
}
`,
{ cssOrigin: 'user' },
)
}
},
startVideo: async (context, event) => {
const { content, view } = context
if (content.kind !== 'video' && content.kind !== 'audio') {
return
}
const wc = view.webContents
// TODO: generalize "video" terminology to "media"
const info = await wc.executeJavaScript(`
(function() {
const sleep = ms => new Promise((resolve) => setTimeout(resolve, ms))
const kind = ${JSON.stringify(
content.kind === 'video' ? 'video' : 'audio',
)}
async function waitForVideo() {
let tries = 0
let video
while ((!video || !video.src) && tries < 10) {
video = document.querySelector(kind)
tries++
await sleep(200)
}
if (video) {
return {video}
}
tries = 0
let iframe
while ((!video || !video.src) && tries < 10) {
for (iframe of document.querySelectorAll('iframe')) {
if (!iframe.contentDocument) {
continue
}
video = iframe.contentDocument.querySelector(kind)
if (video) {
break
}
}
tries++
await sleep(200)
}
if (video) {
return { video, iframe }
}
return {}
}
const periscopeHacks = {
isMatch() {
return location.host === 'www.pscp.tv' || location.host === 'www.periscope.tv'
},
onLoad() {
const playButton = document.querySelector('.PlayButton')
if (playButton) {
playButton.click()
}
},
afterPlay(video) {
const baseVideoEl = document.querySelector('div.BaseVideo')
if (!baseVideoEl) {
return
}
function positionPeriscopeVideo() {
// Periscope videos can be rotated using transform matrix. They need to be rotated correctly.
const tr = baseVideoEl.style.transform
if (tr.endsWith('matrix(0, 1, -1, 0, 0, 0)')) {
video.className = '__rot90__'
} else if (tr.endsWith('matrix(-1, 0, 0, -1, 0)')) {
video.className = '__rot180__'
} else if (tr.endsWith('matrix(0, -1, 1, 0, 0, 0)')) {
video.className = '__rot270__'
}
}
positionPeriscopeVideo()
const obs = new MutationObserver(ml => {
for (const m of ml) {
if (m.attributeName === 'style') {
positionPeriscopeVideo()
return
}
}
})
obs.observe(baseVideoEl, {attributes: true})
},
}
async function findVideo() {
if (periscopeHacks.isMatch()) {
periscopeHacks.onLoad()
}
const { video, iframe } = await waitForVideo()
if (!video) {
throw new Error('could not find video')
}
if (iframe) {
const style = iframe.contentDocument.createElement('style')
style.innerHTML = \`${VIDEO_OVERRIDE_STYLE}\`
iframe.contentDocument.head.appendChild(style)
iframe.className = '__video__'
let parentEl = iframe.parentElement
while (parentEl) {
parentEl.className = '__video_parent__'
parentEl = parentEl.parentElement
}
iframe.contentDocument.body.appendChild(video)
} else {
document.body.appendChild(video)
}
video.muted = false
video.autoPlay = true
video.play()
// Prevent sites from re-muting the video (Periscope, I'm looking at you!)
Object.defineProperty(video, 'muted', {writable: true, value: false})
if (periscopeHacks.isMatch()) {
periscopeHacks.afterPlay(video)
}
const info = { title: document.title }
return info
}
findVideo()
}())
`)
return info
wc.loadURL(content.url)
},
},
},