mirror of
https://github.com/streamwall/streamwall.git
synced 2026-01-31 17:32:48 -05:00
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:
259
src/browser/mediaPreload.js
Normal file
259
src/browser/mediaPreload.js
Normal 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 })
|
||||||
|
})
|
||||||
@@ -8,6 +8,14 @@ import { interpret } from 'xstate'
|
|||||||
import viewStateMachine from './viewStateMachine'
|
import viewStateMachine from './viewStateMachine'
|
||||||
import { boxesFromViewContentMap } from '../geometry'
|
import { boxesFromViewContentMap } from '../geometry'
|
||||||
|
|
||||||
|
function getDisplayOptions(stream) {
|
||||||
|
if (!stream) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
const { rotation } = stream
|
||||||
|
return { rotation }
|
||||||
|
}
|
||||||
|
|
||||||
export default class StreamWindow extends EventEmitter {
|
export default class StreamWindow extends EventEmitter {
|
||||||
constructor(config) {
|
constructor(config) {
|
||||||
super()
|
super()
|
||||||
@@ -21,7 +29,8 @@ export default class StreamWindow extends EventEmitter {
|
|||||||
this.offscreenWin = null
|
this.offscreenWin = null
|
||||||
this.backgroundView = null
|
this.backgroundView = null
|
||||||
this.overlayView = null
|
this.overlayView = null
|
||||||
this.views = []
|
this.views = new Map()
|
||||||
|
this.viewsByURL = new Map()
|
||||||
this.viewActions = null
|
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', () => {
|
ipcMain.on('devtools-overlay', () => {
|
||||||
overlayView.webContents.openDevTools()
|
overlayView.webContents.openDevTools()
|
||||||
})
|
})
|
||||||
@@ -129,6 +157,7 @@ export default class StreamWindow extends EventEmitter {
|
|||||||
const { backgroundColor } = this.config
|
const { backgroundColor } = this.config
|
||||||
const view = new BrowserView({
|
const view = new BrowserView({
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
|
preload: path.join(app.getAppPath(), 'mediaPreload.js'),
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
enableRemoteModule: false,
|
enableRemoteModule: false,
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
@@ -137,6 +166,8 @@ export default class StreamWindow extends EventEmitter {
|
|||||||
})
|
})
|
||||||
view.setBackgroundColor(backgroundColor)
|
view.setBackgroundColor(backgroundColor)
|
||||||
|
|
||||||
|
const viewId = view.webContents.id
|
||||||
|
|
||||||
// Prevent view pages from navigating away from the specified URL.
|
// Prevent view pages from navigating away from the specified URL.
|
||||||
view.webContents.on('will-navigate', (ev) => {
|
view.webContents.on('will-navigate', (ev) => {
|
||||||
ev.preventDefault()
|
ev.preventDefault()
|
||||||
@@ -145,37 +176,42 @@ export default class StreamWindow extends EventEmitter {
|
|||||||
const machine = viewStateMachine
|
const machine = viewStateMachine
|
||||||
.withContext({
|
.withContext({
|
||||||
...viewStateMachine.context,
|
...viewStateMachine.context,
|
||||||
|
id: viewId,
|
||||||
view,
|
view,
|
||||||
parentWin: win,
|
parentWin: win,
|
||||||
overlayView,
|
overlayView,
|
||||||
})
|
})
|
||||||
.withConfig({ actions: viewActions })
|
.withConfig({ actions: viewActions })
|
||||||
const service = interpret(machine).start()
|
const service = interpret(machine).start()
|
||||||
service.onTransition(this.emitState.bind(this))
|
service.onTransition((state) => {
|
||||||
|
if (!state.changed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.emitState(state)
|
||||||
|
})
|
||||||
|
|
||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
|
|
||||||
emitState() {
|
emitState() {
|
||||||
this.emit(
|
const states = Array.from(this.views.values(), ({ state }) => ({
|
||||||
'state',
|
|
||||||
this.views.map(({ state }) => ({
|
|
||||||
state: state.value,
|
state: state.value,
|
||||||
context: {
|
context: {
|
||||||
|
id: state.context.id,
|
||||||
content: state.context.content,
|
content: state.context.content,
|
||||||
info: state.context.info,
|
info: state.context.info,
|
||||||
pos: state.context.pos,
|
pos: state.context.pos,
|
||||||
},
|
},
|
||||||
})),
|
}))
|
||||||
)
|
this.emit('state', states)
|
||||||
}
|
}
|
||||||
|
|
||||||
setViews(viewContentMap) {
|
setViews(viewContentMap, streams) {
|
||||||
const { gridCount, spaceWidth, spaceHeight } = this.config
|
const { gridCount, spaceWidth, spaceHeight } = this.config
|
||||||
const { win, views } = this
|
const { win, views } = this
|
||||||
const boxes = boxesFromViewContentMap(gridCount, gridCount, viewContentMap)
|
const boxes = boxesFromViewContentMap(gridCount, gridCount, viewContentMap)
|
||||||
const remainingBoxes = new Set(boxes)
|
const remainingBoxes = new Set(boxes)
|
||||||
const unusedViews = new Set(views)
|
const unusedViews = new Set(views.values())
|
||||||
const viewsToDisplay = []
|
const viewsToDisplay = []
|
||||||
|
|
||||||
// We try to find the best match for moving / reusing existing views to match the new positions.
|
// 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 })
|
viewsToDisplay.push({ box, view })
|
||||||
}
|
}
|
||||||
|
|
||||||
const newViews = []
|
const newViews = new Map()
|
||||||
|
const newViewsByURL = new Map()
|
||||||
for (const { box, view } of viewsToDisplay) {
|
for (const { box, view } of viewsToDisplay) {
|
||||||
const { content, x, y, w, h, spaces } = box
|
const { content, x, y, w, h, spaces } = box
|
||||||
const pos = {
|
const pos = {
|
||||||
@@ -226,8 +263,11 @@ export default class StreamWindow extends EventEmitter {
|
|||||||
height: spaceHeight * h,
|
height: spaceHeight * h,
|
||||||
spaces,
|
spaces,
|
||||||
}
|
}
|
||||||
|
const stream = streams.find((s) => s.url === content.url)
|
||||||
|
view.send({ type: 'OPTIONS', options: getDisplayOptions(stream) })
|
||||||
view.send({ type: 'DISPLAY', pos, content })
|
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) {
|
for (const view of unusedViews) {
|
||||||
const browserView = view.state.context.view
|
const browserView = view.state.context.view
|
||||||
@@ -235,12 +275,13 @@ export default class StreamWindow extends EventEmitter {
|
|||||||
browserView.destroy()
|
browserView.destroy()
|
||||||
}
|
}
|
||||||
this.views = newViews
|
this.views = newViews
|
||||||
|
this.viewsByURL = newViewsByURL
|
||||||
this.emitState()
|
this.emitState()
|
||||||
}
|
}
|
||||||
|
|
||||||
setListeningView(viewIdx) {
|
setListeningView(viewIdx) {
|
||||||
const { views } = this
|
const { views } = this
|
||||||
for (const view of views) {
|
for (const view of views.values()) {
|
||||||
if (!view.state.matches('displaying')) {
|
if (!view.state.matches('displaying')) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -251,10 +292,11 @@ export default class StreamWindow extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
findViewByIdx(viewIdx) {
|
findViewByIdx(viewIdx) {
|
||||||
return this.views.find(
|
for (const view of this.views.values()) {
|
||||||
(v) =>
|
if (view.state.context.pos?.spaces?.includes?.(viewIdx)) {
|
||||||
v.state.context.pos && v.state.context.pos.spaces.includes(viewIdx),
|
return view
|
||||||
)
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sendViewEvent(viewIdx, event) {
|
sendViewEvent(viewIdx, event) {
|
||||||
@@ -280,6 +322,17 @@ export default class StreamWindow extends EventEmitter {
|
|||||||
this.sendViewEvent(viewIdx, { type: 'DEVTOOLS', inWebContents })
|
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) {
|
send(...args) {
|
||||||
this.overlayView.webContents.send(...args)
|
this.overlayView.webContents.send(...args)
|
||||||
this.backgroundView.webContents.send(...args)
|
this.backgroundView.webContents.send(...args)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { once } from 'events'
|
import { EventEmitter, once } from 'events'
|
||||||
import { promises as fsPromises } from 'fs'
|
import { promises as fsPromises } from 'fs'
|
||||||
import { promisify } from 'util'
|
import { promisify } from 'util'
|
||||||
import { Repeater } from '@repeaterjs/repeater'
|
import { Repeater } from '@repeaterjs/repeater'
|
||||||
@@ -60,7 +60,51 @@ export async function* markDataSource(dataSource, name) {
|
|||||||
|
|
||||||
export async function* combineDataSources(dataSources) {
|
export async function* combineDataSources(dataSources) {
|
||||||
for await (const streamLists of Repeater.latest(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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import path from 'path'
|
|||||||
import yargs from 'yargs'
|
import yargs from 'yargs'
|
||||||
import TOML from '@iarna/toml'
|
import TOML from '@iarna/toml'
|
||||||
import * as Y from 'yjs'
|
import * as Y from 'yjs'
|
||||||
import { Repeater } from '@repeaterjs/repeater'
|
|
||||||
import * as Sentry from '@sentry/electron'
|
import * as Sentry from '@sentry/electron'
|
||||||
import { app, shell, session, BrowserWindow } from 'electron'
|
import { app, shell, session, BrowserWindow } from 'electron'
|
||||||
|
|
||||||
@@ -11,6 +10,7 @@ import { ensureValidURL } from '../util'
|
|||||||
import {
|
import {
|
||||||
pollDataURL,
|
pollDataURL,
|
||||||
watchDataFile,
|
watchDataFile,
|
||||||
|
LocalStreamData,
|
||||||
StreamIDGenerator,
|
StreamIDGenerator,
|
||||||
markDataSource,
|
markDataSource,
|
||||||
combineDataSources,
|
combineDataSources,
|
||||||
@@ -230,11 +230,8 @@ async function main() {
|
|||||||
const persistData = await persistence.load()
|
const persistData = await persistence.load()
|
||||||
|
|
||||||
const idGen = new StreamIDGenerator()
|
const idGen = new StreamIDGenerator()
|
||||||
let updateCustomStreams
|
const localStreamData = new LocalStreamData()
|
||||||
const customStreamData = new Repeater(async (push) => {
|
const overlayStreamData = new LocalStreamData()
|
||||||
await push([])
|
|
||||||
updateCustomStreams = push
|
|
||||||
})
|
|
||||||
|
|
||||||
const streamWindow = new StreamWindow({
|
const streamWindow = new StreamWindow({
|
||||||
gridCount: argv.grid.count,
|
gridCount: argv.grid.count,
|
||||||
@@ -267,7 +264,6 @@ async function main() {
|
|||||||
},
|
},
|
||||||
auth: auth.getState(),
|
auth: auth.getState(),
|
||||||
streams: [],
|
streams: [],
|
||||||
customStreams: [],
|
|
||||||
views: [],
|
views: [],
|
||||||
streamdelay: null,
|
streamdelay: null,
|
||||||
})
|
})
|
||||||
@@ -282,6 +278,7 @@ async function main() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
viewsState.observeDeep(() => {
|
viewsState.observeDeep(() => {
|
||||||
|
try {
|
||||||
const viewContentMap = new Map()
|
const viewContentMap = new Map()
|
||||||
for (const [key, viewData] of viewsState) {
|
for (const [key, viewData] of viewsState) {
|
||||||
const stream = clientState.info.streams.find(
|
const stream = clientState.info.streams.find(
|
||||||
@@ -295,7 +292,10 @@ async function main() {
|
|||||||
kind: stream.kind || 'video',
|
kind: stream.kind || 'video',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
streamWindow.setViews(viewContentMap)
|
streamWindow.setViews(viewContentMap, clientState.info.streams)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating views', err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const onMessage = async (msg, respond) => {
|
const onMessage = async (msg, respond) => {
|
||||||
@@ -305,8 +305,14 @@ async function main() {
|
|||||||
streamWindow.setViewBackgroundListening(msg.viewIdx, msg.listening)
|
streamWindow.setViewBackgroundListening(msg.viewIdx, msg.listening)
|
||||||
} else if (msg.type === 'set-view-blurred') {
|
} else if (msg.type === 'set-view-blurred') {
|
||||||
streamWindow.setViewBlurred(msg.viewIdx, msg.blurred)
|
streamWindow.setViewBlurred(msg.viewIdx, msg.blurred)
|
||||||
} else if (msg.type === 'set-custom-streams') {
|
} else if (msg.type === 'rotate-stream') {
|
||||||
updateCustomStreams(msg.streams)
|
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') {
|
} else if (msg.type === 'reload-view') {
|
||||||
streamWindow.reloadView(msg.viewIdx)
|
streamWindow.reloadView(msg.viewIdx)
|
||||||
} else if (msg.type === 'browse' || msg.type === 'dev-tools') {
|
} else if (msg.type === 'browse' || msg.type === 'dev-tools') {
|
||||||
@@ -353,7 +359,7 @@ async function main() {
|
|||||||
|
|
||||||
function updateState(newState) {
|
function updateState(newState) {
|
||||||
clientState.update(newState)
|
clientState.update(newState)
|
||||||
streamWindow.send('state', clientState.info)
|
streamWindow.onState(clientState.info)
|
||||||
if (twitchBot) {
|
if (twitchBot) {
|
||||||
twitchBot.onState(clientState.info)
|
twitchBot.onState(clientState.info)
|
||||||
}
|
}
|
||||||
@@ -419,7 +425,8 @@ async function main() {
|
|||||||
...argv.data['toml-file'].map((path) =>
|
...argv.data['toml-file'].map((path) =>
|
||||||
markDataSource(watchDataFile(path), 'toml-file'),
|
markDataSource(watchDataFile(path), 'toml-file'),
|
||||||
),
|
),
|
||||||
markDataSource(customStreamData, 'custom'),
|
markDataSource(localStreamData.gen(), 'custom'),
|
||||||
|
overlayStreamData.gen(),
|
||||||
]
|
]
|
||||||
|
|
||||||
for await (const rawStreams of combineDataSources(dataSources)) {
|
for await (const rawStreams of combineDataSources(dataSources)) {
|
||||||
|
|||||||
@@ -3,69 +3,16 @@ import { Machine, assign } from 'xstate'
|
|||||||
|
|
||||||
import { ensureValidURL } from '../util'
|
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(
|
const viewStateMachine = Machine(
|
||||||
{
|
{
|
||||||
id: 'view',
|
id: 'view',
|
||||||
initial: 'empty',
|
initial: 'empty',
|
||||||
context: {
|
context: {
|
||||||
|
id: null,
|
||||||
view: null,
|
view: null,
|
||||||
pos: null,
|
pos: null,
|
||||||
content: null,
|
content: null,
|
||||||
|
options: null,
|
||||||
info: {},
|
info: {},
|
||||||
},
|
},
|
||||||
on: {
|
on: {
|
||||||
@@ -87,39 +34,50 @@ const viewStateMachine = Machine(
|
|||||||
}),
|
}),
|
||||||
cond: 'contentUnchanged',
|
cond: 'contentUnchanged',
|
||||||
},
|
},
|
||||||
|
OPTIONS: {
|
||||||
|
actions: [
|
||||||
|
assign({
|
||||||
|
options: (context, event) => event.options,
|
||||||
|
}),
|
||||||
|
'sendViewOptions',
|
||||||
|
],
|
||||||
|
cond: 'optionsChanged',
|
||||||
|
},
|
||||||
RELOAD: '.loading',
|
RELOAD: '.loading',
|
||||||
DEVTOOLS: {
|
DEVTOOLS: {
|
||||||
actions: 'openDevTools',
|
actions: 'openDevTools',
|
||||||
},
|
},
|
||||||
|
VIEW_ERROR: '.error',
|
||||||
|
VIEW_INFO: {
|
||||||
|
actions: assign({
|
||||||
|
info: (context, event) => event.info,
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
states: {
|
states: {
|
||||||
loading: {
|
loading: {
|
||||||
initial: 'page',
|
initial: 'navigate',
|
||||||
entry: 'offscreenView',
|
entry: 'offscreenView',
|
||||||
states: {
|
states: {
|
||||||
page: {
|
navigate: {
|
||||||
invoke: {
|
invoke: {
|
||||||
src: 'loadPage',
|
src: 'loadPage',
|
||||||
onDone: {
|
onDone: {
|
||||||
target: 'video',
|
target: 'waitForInit',
|
||||||
},
|
},
|
||||||
onError: {
|
onError: {
|
||||||
target: '#view.displaying.error',
|
target: '#view.displaying.error',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
video: {
|
waitForInit: {
|
||||||
invoke: {
|
on: {
|
||||||
src: 'startVideo',
|
VIEW_INIT: 'waitForVideo',
|
||||||
onDone: {
|
|
||||||
target: '#view.displaying.running',
|
|
||||||
actions: assign({
|
|
||||||
info: (context, event) => event.data,
|
|
||||||
}),
|
|
||||||
},
|
},
|
||||||
onError: {
|
|
||||||
target: '#view.displaying.error',
|
|
||||||
},
|
},
|
||||||
|
waitForVideo: {
|
||||||
|
on: {
|
||||||
|
VIEW_LOADED: '#view.displaying.running',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -200,11 +158,18 @@ const viewStateMachine = Machine(
|
|||||||
view.webContents.setDevToolsWebContents(inWebContents)
|
view.webContents.setDevToolsWebContents(inWebContents)
|
||||||
view.webContents.openDevTools({ mode: 'detach' })
|
view.webContents.openDevTools({ mode: 'detach' })
|
||||||
},
|
},
|
||||||
|
sendViewOptions: (context, event) => {
|
||||||
|
const { view } = context
|
||||||
|
view.webContents.send('options', event.options)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
guards: {
|
guards: {
|
||||||
contentUnchanged: (context, event) => {
|
contentUnchanged: (context, event) => {
|
||||||
return isEqual(context.content, event.content)
|
return isEqual(context.content, event.content)
|
||||||
},
|
},
|
||||||
|
optionsChanged: (context, event) => {
|
||||||
|
return !isEqual(context.options, event.options)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
services: {
|
services: {
|
||||||
loadPage: async (context, event) => {
|
loadPage: async (context, event) => {
|
||||||
@@ -212,149 +177,7 @@ const viewStateMachine = Machine(
|
|||||||
ensureValidURL(content.url)
|
ensureValidURL(content.url)
|
||||||
const wc = view.webContents
|
const wc = view.webContents
|
||||||
wc.audioMuted = true
|
wc.audioMuted = true
|
||||||
await wc.loadURL(content.url)
|
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
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ const operatorActions = new Set([
|
|||||||
'set-view-blurred',
|
'set-view-blurred',
|
||||||
'set-custom-streams',
|
'set-custom-streams',
|
||||||
'reload-view',
|
'reload-view',
|
||||||
|
'rotate-view',
|
||||||
'set-stream-censored',
|
'set-stream-censored',
|
||||||
'set-stream-running',
|
'set-stream-running',
|
||||||
'mutate-state-doc',
|
'mutate-state-doc',
|
||||||
|
|||||||
1
src/static/sync-alt-solid.svg
Normal file
1
src/static/sync-alt-solid.svg
Normal 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 |
@@ -22,7 +22,8 @@ import { idxInBox } from '../geometry'
|
|||||||
import { roleCan } from '../roles'
|
import { roleCan } from '../roles'
|
||||||
import SoundIcon from '../static/volume-up-solid.svg'
|
import SoundIcon from '../static/volume-up-solid.svg'
|
||||||
import NoVideoIcon from '../static/video-slash-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 SwapIcon from '../static/exchange-alt-solid.svg'
|
||||||
import LifeRingIcon from '../static/life-ring-regular.svg'
|
import LifeRingIcon from '../static/life-ring-regular.svg'
|
||||||
import WindowIcon from '../static/window-maximize-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(
|
const handleBrowse = useCallback(
|
||||||
(streamId) => {
|
(streamId) => {
|
||||||
const stream = streams.find((d) => d._id === streamId)
|
const stream = streams.find((d) => d._id === streamId)
|
||||||
@@ -449,13 +465,18 @@ function App({ wsEndpoint, role }) {
|
|||||||
[gridCount, sharedState, focusedInputIdx],
|
[gridCount, sharedState, focusedInputIdx],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleChangeCustomStream = useCallback((idx, customStream) => {
|
const handleChangeCustomStream = useCallback((origLink, customStream) => {
|
||||||
let newCustomStreams = [...customStreams]
|
if (!customStream.label && !customStream.link) {
|
||||||
newCustomStreams[idx] = customStream
|
|
||||||
newCustomStreams = newCustomStreams.filter((s) => s.label || s.link)
|
|
||||||
send({
|
send({
|
||||||
type: 'set-custom-streams',
|
type: 'delete-custom-stream',
|
||||||
streams: newCustomStreams,
|
url: origLink,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
send({
|
||||||
|
type: 'update-custom-stream',
|
||||||
|
url: origLink,
|
||||||
|
data: customStream,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -665,6 +686,7 @@ function App({ wsEndpoint, role }) {
|
|||||||
onSetBlurred={handleSetBlurred}
|
onSetBlurred={handleSetBlurred}
|
||||||
onReloadView={handleReloadView}
|
onReloadView={handleReloadView}
|
||||||
onSwapView={handleSwapView}
|
onSwapView={handleSwapView}
|
||||||
|
onRotateView={handleRotateStream}
|
||||||
onBrowse={handleBrowse}
|
onBrowse={handleBrowse}
|
||||||
onDevTools={handleDevTools}
|
onDevTools={handleDevTools}
|
||||||
onMouseDown={handleDragStart}
|
onMouseDown={handleDragStart}
|
||||||
@@ -711,7 +733,6 @@ function App({ wsEndpoint, role }) {
|
|||||||
({ link, label, kind }, idx) => (
|
({ link, label, kind }, idx) => (
|
||||||
<CustomStreamInput
|
<CustomStreamInput
|
||||||
key={idx}
|
key={idx}
|
||||||
idx={idx}
|
|
||||||
link={link}
|
link={link}
|
||||||
label={label}
|
label={label}
|
||||||
kind={kind}
|
kind={kind}
|
||||||
@@ -934,6 +955,7 @@ function GridControls({
|
|||||||
onSetBlurred,
|
onSetBlurred,
|
||||||
onReloadView,
|
onReloadView,
|
||||||
onSwapView,
|
onSwapView,
|
||||||
|
onRotateView,
|
||||||
onBrowse,
|
onBrowse,
|
||||||
onDevTools,
|
onDevTools,
|
||||||
onMouseDown,
|
onMouseDown,
|
||||||
@@ -963,6 +985,10 @@ function GridControls({
|
|||||||
onReloadView,
|
onReloadView,
|
||||||
])
|
])
|
||||||
const handleSwapClick = useCallback(() => onSwapView(idx), [idx, onSwapView])
|
const handleSwapClick = useCallback(() => onSwapView(idx), [idx, onSwapView])
|
||||||
|
const handleRotateClick = useCallback(() => onRotateView(streamId), [
|
||||||
|
streamId,
|
||||||
|
onRotateView,
|
||||||
|
])
|
||||||
const handleBrowseClick = useCallback(() => onBrowse(streamId), [
|
const handleBrowseClick = useCallback(() => onBrowse(streamId), [
|
||||||
streamId,
|
streamId,
|
||||||
onBrowse,
|
onBrowse,
|
||||||
@@ -1004,6 +1030,11 @@ function GridControls({
|
|||||||
<SwapIcon />
|
<SwapIcon />
|
||||||
</StyledSmallButton>
|
</StyledSmallButton>
|
||||||
)}
|
)}
|
||||||
|
{roleCan(role, 'rotate-view') && (
|
||||||
|
<StyledSmallButton onClick={handleRotateClick} tabIndex={1}>
|
||||||
|
<RotateIcon />
|
||||||
|
</StyledSmallButton>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</StyledGridButtons>
|
</StyledGridButtons>
|
||||||
@@ -1033,22 +1064,22 @@ function GridControls({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function CustomStreamInput({ idx, onChange, ...props }) {
|
function CustomStreamInput({ onChange, ...props }) {
|
||||||
const handleChangeLink = useCallback(
|
const handleChangeLink = useCallback(
|
||||||
(ev) => {
|
(ev) => {
|
||||||
onChange(idx, { ...props, link: ev.target.value })
|
onChange(props.link, { ...props, link: ev.target.value })
|
||||||
},
|
},
|
||||||
[onChange],
|
[onChange],
|
||||||
)
|
)
|
||||||
const handleChangeLabel = useCallback(
|
const handleChangeLabel = useCallback(
|
||||||
(ev) => {
|
(ev) => {
|
||||||
onChange(idx, { ...props, label: ev.target.value })
|
onChange(props.link, { ...props, label: ev.target.value })
|
||||||
},
|
},
|
||||||
[onChange],
|
[onChange],
|
||||||
)
|
)
|
||||||
const handleChangeKind = useCallback(
|
const handleChangeKind = useCallback(
|
||||||
(ev) => {
|
(ev) => {
|
||||||
onChange(idx, { ...props, kind: ev.target.value })
|
onChange(props.link, { ...props, kind: ev.target.value })
|
||||||
},
|
},
|
||||||
[onChange],
|
[onChange],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ const browserConfig = {
|
|||||||
background: './src/browser/background.js',
|
background: './src/browser/background.js',
|
||||||
overlay: './src/browser/overlay.js',
|
overlay: './src/browser/overlay.js',
|
||||||
layerPreload: './src/browser/layerPreload.js',
|
layerPreload: './src/browser/layerPreload.js',
|
||||||
|
mediaPreload: './src/browser/mediaPreload.js',
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
new CopyPlugin({
|
new CopyPlugin({
|
||||||
|
|||||||
Reference in New Issue
Block a user