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

259
src/browser/mediaPreload.js Normal file
View File

@@ -0,0 +1,259 @@
import { ipcRenderer, webFrame } from 'electron'
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 NO_SCROLL_STYLE = `
html, body {
overflow: hidden !important;
}
`
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
class RotationController {
constructor(video) {
this.video = video
this.siteRotation = 0
this.customRotation = 0
}
_update() {
const rotation = (this.siteRotation + this.customRotation) % 360
if (![0, 90, 180, 270].includes(rotation)) {
console.warn('ignoring invalid rotation', rotation)
}
this.video.className = `__rot${rotation}__`
}
setSite(rotation) {
this.siteRotation = rotation
this._update()
}
setCustom(rotation) {
this.customRotation = rotation
this._update()
}
}
function lockdownMediaTags() {
webFrame.executeJavaScript(`
for (const el of document.querySelectorAll('video, audio')) {
if (el.__sw) {
continue
}
// Prevent sites from re-muting the video (Periscope, I'm looking at you!)
Object.defineProperty(el, 'muted', { writable: true, value: false })
// Prevent Facebook from pausing the video after page load.
Object.defineProperty(el, 'pause', { writable: false, value: () => {} })
el.__sw = true
}
`)
}
// Watch for media tags and mute them as soon as possible.
function watchMediaTags(kind, onFirstOfKind) {
let foundMatch = false
const observer = new MutationObserver((mutationList) => {
if (kind) {
const el = document.querySelector(kind)
if (el && !foundMatch) {
onFirstOfKind(el)
foundMatch = true
}
}
lockdownMediaTags()
})
document.addEventListener('DOMContentLoaded', () => {
observer.observe(document.body, { subtree: true, childList: true })
})
}
async function waitForVideo(kind) {
const waitForTag = new Promise((resolve) => watchMediaTags(kind, resolve))
let video = await Promise.race([waitForTag, sleep(10000)])
if (video) {
return { video }
}
let iframe
for (iframe of document.querySelectorAll('iframe')) {
video = iframe.contentDocument?.querySelector?.(kind)
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(rotationController) {
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
let rotation
if (tr.endsWith('matrix(0, 1, -1, 0, 0, 0)')) {
rotation = 90
} else if (tr.endsWith('matrix(-1, 0, 0, -1, 0)')) {
rotation = 180
} else if (tr.endsWith('matrix(0, -1, 1, 0, 0, 0)')) {
rotation = 270
}
rotationController.setSite(rotation)
}
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(kind) {
if (periscopeHacks.isMatch()) {
periscopeHacks.onLoad()
}
const { video, iframe } = await waitForVideo(kind)
if (!video) {
throw new Error('could not find video')
}
if (iframe) {
// TODO: verify iframe still works
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)
}
if (!video.videoWidth) {
const videoReady = new Promise((resolve) =>
video.addEventListener('canplay', resolve, { once: true }),
)
await videoReady
}
video.play()
const info = {
title: document.title,
}
return { info, video }
}
async function main() {
const viewInit = ipcRenderer.invoke('view-init')
const pageReady = new Promise((resolve) => process.once('loaded', resolve))
const [{ content }] = await Promise.all([viewInit, pageReady])
let rotationController
if (content.kind === 'video' || content.kind === 'audio') {
webFrame.insertCSS(VIDEO_OVERRIDE_STYLE, { cssOrigin: 'user' })
const { info, video } = await findVideo(content.kind)
if (content.kind === 'video') {
rotationController = new RotationController(video)
if (periscopeHacks.isMatch()) {
periscopeHacks.afterPlay(rotationController)
}
}
ipcRenderer.send('view-info', { info })
} else if (content.kind === 'web') {
webFrame.insertCSS(NO_SCROLL_STYLE, { cssOrigin: 'user' })
}
ipcRenderer.send('view-loaded')
ipcRenderer.on('options', (ev, options) => {
if (rotationController) {
rotationController.setCustom(options.rotation)
}
})
}
main().catch((err) => {
ipcRenderer.send('view-error', { err })
})

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)
},
},
},

View File

@@ -4,6 +4,7 @@ const operatorActions = new Set([
'set-view-blurred',
'set-custom-streams',
'reload-view',
'rotate-view',
'set-stream-censored',
'set-stream-running',
'mutate-state-doc',

View File

@@ -0,0 +1 @@
<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="sync-alt" class="svg-inline--fa fa-sync-alt fa-w-16" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M370.72 133.28C339.458 104.008 298.888 87.962 255.848 88c-77.458.068-144.328 53.178-162.791 126.85-1.344 5.363-6.122 9.15-11.651 9.15H24.103c-7.498 0-13.194-6.807-11.807-14.176C33.933 94.924 134.813 8 256 8c66.448 0 126.791 26.136 171.315 68.685L463.03 40.97C478.149 25.851 504 36.559 504 57.941V192c0 13.255-10.745 24-24 24H345.941c-21.382 0-32.09-25.851-16.971-40.971l41.75-41.749zM32 296h134.059c21.382 0 32.09 25.851 16.971 40.971l-41.75 41.75c31.262 29.273 71.835 45.319 114.876 45.28 77.418-.07 144.315-53.144 162.787-126.849 1.344-5.363 6.122-9.15 11.651-9.15h57.304c7.498 0 13.194 6.807 11.807 14.176C478.067 417.076 377.187 504 256 504c-66.448 0-126.791-26.136-171.315-68.685L48.97 471.03C33.851 486.149 8 475.441 8 454.059V320c0-13.255 10.745-24 24-24z"></path></svg>

After

Width:  |  Height:  |  Size: 998 B

View File

@@ -22,7 +22,8 @@ import { idxInBox } from '../geometry'
import { roleCan } from '../roles'
import SoundIcon from '../static/volume-up-solid.svg'
import NoVideoIcon from '../static/video-slash-solid.svg'
import ReloadIcon from '../static/redo-alt-solid.svg'
import ReloadIcon from '../static/sync-alt-solid.svg'
import RotateIcon from '../static/redo-alt-solid.svg'
import SwapIcon from '../static/exchange-alt-solid.svg'
import LifeRingIcon from '../static/life-ring-regular.svg'
import WindowIcon from '../static/window-maximize-regular.svg'
@@ -404,6 +405,21 @@ function App({ wsEndpoint, role }) {
})
}, [])
const handleRotateStream = useCallback(
(streamId) => {
const stream = streams.find((d) => d._id === streamId)
if (!stream) {
return
}
send({
type: 'rotate-stream',
url: stream.link,
rotation: ((stream.rotation || 0) + 90) % 360,
})
},
[streams],
)
const handleBrowse = useCallback(
(streamId) => {
const stream = streams.find((d) => d._id === streamId)
@@ -449,13 +465,18 @@ function App({ wsEndpoint, role }) {
[gridCount, sharedState, focusedInputIdx],
)
const handleChangeCustomStream = useCallback((idx, customStream) => {
let newCustomStreams = [...customStreams]
newCustomStreams[idx] = customStream
newCustomStreams = newCustomStreams.filter((s) => s.label || s.link)
const handleChangeCustomStream = useCallback((origLink, customStream) => {
if (!customStream.label && !customStream.link) {
send({
type: 'delete-custom-stream',
url: origLink,
})
return
}
send({
type: 'set-custom-streams',
streams: newCustomStreams,
type: 'update-custom-stream',
url: origLink,
data: customStream,
})
})
@@ -665,6 +686,7 @@ function App({ wsEndpoint, role }) {
onSetBlurred={handleSetBlurred}
onReloadView={handleReloadView}
onSwapView={handleSwapView}
onRotateView={handleRotateStream}
onBrowse={handleBrowse}
onDevTools={handleDevTools}
onMouseDown={handleDragStart}
@@ -711,7 +733,6 @@ function App({ wsEndpoint, role }) {
({ link, label, kind }, idx) => (
<CustomStreamInput
key={idx}
idx={idx}
link={link}
label={label}
kind={kind}
@@ -934,6 +955,7 @@ function GridControls({
onSetBlurred,
onReloadView,
onSwapView,
onRotateView,
onBrowse,
onDevTools,
onMouseDown,
@@ -963,6 +985,10 @@ function GridControls({
onReloadView,
])
const handleSwapClick = useCallback(() => onSwapView(idx), [idx, onSwapView])
const handleRotateClick = useCallback(() => onRotateView(streamId), [
streamId,
onRotateView,
])
const handleBrowseClick = useCallback(() => onBrowse(streamId), [
streamId,
onBrowse,
@@ -1004,6 +1030,11 @@ function GridControls({
<SwapIcon />
</StyledSmallButton>
)}
{roleCan(role, 'rotate-view') && (
<StyledSmallButton onClick={handleRotateClick} tabIndex={1}>
<RotateIcon />
</StyledSmallButton>
)}
</>
)}
</StyledGridButtons>
@@ -1033,22 +1064,22 @@ function GridControls({
)
}
function CustomStreamInput({ idx, onChange, ...props }) {
function CustomStreamInput({ onChange, ...props }) {
const handleChangeLink = useCallback(
(ev) => {
onChange(idx, { ...props, link: ev.target.value })
onChange(props.link, { ...props, link: ev.target.value })
},
[onChange],
)
const handleChangeLabel = useCallback(
(ev) => {
onChange(idx, { ...props, label: ev.target.value })
onChange(props.link, { ...props, label: ev.target.value })
},
[onChange],
)
const handleChangeKind = useCallback(
(ev) => {
onChange(idx, { ...props, kind: ev.target.value })
onChange(props.link, { ...props, kind: ev.target.value })
},
[onChange],
)

View File

@@ -81,6 +81,7 @@ const browserConfig = {
background: './src/browser/background.js',
overlay: './src/browser/overlay.js',
layerPreload: './src/browser/layerPreload.js',
mediaPreload: './src/browser/mediaPreload.js',
},
plugins: [
new CopyPlugin({