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({