mirror of
https://github.com/streamwall/streamwall.git
synced 2026-01-25 06:32:49 -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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user