From c219656564d7fac972f7fceb4164ce84c35dcaa4 Mon Sep 17 00:00:00 2001 From: Max Goodhart Date: Sun, 8 Nov 2020 13:07:48 -0800 Subject: [PATCH] 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. --- src/browser/mediaPreload.js | 259 ++++++++++++++++++++++++++++++++++ src/node/StreamWindow.js | 97 ++++++++++--- src/node/data.js | 48 ++++++- src/node/index.js | 53 ++++--- src/node/viewStateMachine.js | 249 +++++--------------------------- src/roles.js | 1 + src/static/sync-alt-solid.svg | 1 + src/web/control.js | 55 ++++++-- webpack.config.js | 1 + 9 files changed, 492 insertions(+), 272 deletions(-) create mode 100644 src/browser/mediaPreload.js create mode 100644 src/static/sync-alt-solid.svg diff --git a/src/browser/mediaPreload.js b/src/browser/mediaPreload.js new file mode 100644 index 0000000..60d51ef --- /dev/null +++ b/src/browser/mediaPreload.js @@ -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 }) +}) diff --git a/src/node/StreamWindow.js b/src/node/StreamWindow.js index 533394c..6301db9 100644 --- a/src/node/StreamWindow.js +++ b/src/node/StreamWindow.js @@ -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) diff --git a/src/node/data.js b/src/node/data.js index c97d5b0..caf6295 100644 --- a/src/node/data.js +++ b/src/node/data.js @@ -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) + }) } } diff --git a/src/node/index.js b/src/node/index.js index fa8a89a..b288a2f 100644 --- a/src/node/index.js +++ b/src/node/index.js @@ -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)) { diff --git a/src/node/viewStateMachine.js b/src/node/viewStateMachine.js index b111d80..0da3995 100644 --- a/src/node/viewStateMachine.js +++ b/src/node/viewStateMachine.js @@ -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) }, }, }, diff --git a/src/roles.js b/src/roles.js index ca16550..f46a342 100644 --- a/src/roles.js +++ b/src/roles.js @@ -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', diff --git a/src/static/sync-alt-solid.svg b/src/static/sync-alt-solid.svg new file mode 100644 index 0000000..adacb5d --- /dev/null +++ b/src/static/sync-alt-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/web/control.js b/src/web/control.js index 5dcca7b..19311f1 100644 --- a/src/web/control.js +++ b/src/web/control.js @@ -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) => ( onSwapView(idx), [idx, onSwapView]) + const handleRotateClick = useCallback(() => onRotateView(streamId), [ + streamId, + onRotateView, + ]) const handleBrowseClick = useCallback(() => onBrowse(streamId), [ streamId, onBrowse, @@ -1004,6 +1030,11 @@ function GridControls({ )} + {roleCan(role, 'rotate-view') && ( + + + + )} )} @@ -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], ) diff --git a/webpack.config.js b/webpack.config.js index 81b9b44..ccab303 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -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({